chore: merge master

This commit is contained in:
James
2022-09-11 18:07:05 -07:00
245 changed files with 4708 additions and 2347 deletions

View File

@@ -9,6 +9,7 @@ export const readOnlySlug = 'read-only-collection';
export const restrictedSlug = 'restricted';
export const restrictedVersionsSlug = 'restricted-versions';
export const siblingDataSlug = 'sibling-data';
export const relyOnRequestHeadersSlug = 'rely-on-request-headers';
const openAccess = {
create: () => true,
@@ -24,6 +25,11 @@ const PublicReadabilityAccess: FieldAccess = ({ req: { user }, siblingData }) =>
return false;
};
export const requestHeaders = {authorization: 'Bearer testBearerToken'};
const UseRequestHeadersAccess: FieldAccess = ({ req: { headers } }) => {
return !!headers && headers.authorization === requestHeaders.authorization;
};
export default buildConfig({
collections: [
{
@@ -115,6 +121,21 @@ export default buildConfig({
},
],
},
{
slug: relyOnRequestHeadersSlug,
access: {
create: UseRequestHeadersAccess,
read: UseRequestHeadersAccess,
update: UseRequestHeadersAccess,
delete: UseRequestHeadersAccess,
},
fields: [
{
name: 'name',
type: 'text',
},
],
},
],
onInit: async (payload) => {
await payload.create({

View File

@@ -1,9 +1,11 @@
import mongoose from 'mongoose';
import payload from '../../src';
import type { Options as CreateOptions } from '../../src/collections/operations/local/create';
import { Forbidden } from '../../src/errors';
import type { PayloadRequest } from '../../src/types';
import { initPayloadTest } from '../helpers/configHelpers';
import { restrictedSlug, siblingDataSlug, slug } from './config';
import type { Restricted, Post, SiblingDatum } from './payload-types';
import { relyOnRequestHeadersSlug, requestHeaders, restrictedSlug, siblingDataSlug, slug } from './config';
import type { Restricted, Post, SiblingDatum, RelyOnRequestHeader } from './payload-types';
import { firstArrayText, secondArrayText } from './shared';
describe('Access Control', () => {
@@ -74,7 +76,7 @@ describe('Access Control', () => {
describe('Collections', () => {
describe('restricted collection', () => {
it('field without read access should not show', async () => {
const { id } = await createDoc({ restrictedField: 'restricted' });
const { id } = await createDoc<Post>({ restrictedField: 'restricted' });
const retrievedDoc = await payload.findByID({ collection: slug, id, overrideAccess: false });
@@ -82,7 +84,7 @@ describe('Access Control', () => {
});
it('field without read access should not show when overrideAccess: true', async () => {
const { id, restrictedField } = await createDoc({ restrictedField: 'restricted' });
const { id, restrictedField } = await createDoc<Post>({ restrictedField: 'restricted' });
const retrievedDoc = await payload.findByID({ collection: slug, id, overrideAccess: true });
@@ -90,13 +92,59 @@ describe('Access Control', () => {
});
it('field without read access should not show when overrideAccess default', async () => {
const { id, restrictedField } = await createDoc({ restrictedField: 'restricted' });
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', () => {
@@ -172,9 +220,10 @@ describe('Access Control', () => {
});
});
async function createDoc(data: Partial<Post>): Promise<Post> {
return payload.create({
collection: slug,
async function createDoc<Collection>(data: Partial<Collection>, overrideSlug = slug, options?: Partial<CreateOptions<Collection>>): Promise<Collection> {
return payload.create<Collection>({
...options,
collection: overrideSlug,
data: data ?? {},
});
}

View File

@@ -5,7 +5,7 @@
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {}
export interface Config { }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-global".
@@ -33,9 +33,21 @@ export interface AutosavePost {
*/
export interface DraftPost {
id: string;
_status?: 'draft' | 'published';
title: string;
description: string;
array: {
allowPublicReadability?: boolean;
text?: string;
id?: string;
}[];
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "rely-on-request-headers".
*/
export interface RelyOnRequestHeader {
id: string;
name?: string;
createdAt: string;
updatedAt: string;
}

View File

@@ -1,3 +1,4 @@
import path from 'path';
import { mapAsync } from '../../src/utilities/mapAsync';
import { devUser } from '../credentials';
import { buildConfig } from '../buildConfig';
@@ -18,6 +19,7 @@ export interface Post {
export default buildConfig({
admin: {
css: path.resolve(__dirname, 'styles.scss'),
components: {
// providers: [CustomProvider, CustomProvider],
routes: [

View File

@@ -120,17 +120,6 @@ describe('admin', () => {
expect(page.url()).toContain(url.list);
});
test('should duplicate existing', async () => {
const { id } = await createPost();
await page.goto(url.edit(id));
await page.locator('#action-duplicate').click();
expect(page.url()).toContain(url.create);
await page.locator('#action-save').click();
expect(page.url()).not.toContain(id); // new id
});
test('should save globals', async () => {
await page.goto(url.global(globalSlug));
@@ -236,38 +225,39 @@ describe('admin', () => {
});
});
describe('sorting', () => {
describe('custom css', () => {
test('should see custom css in admin UI', async () => {
await page.goto(url.admin);
const navControls = await page.locator('.nav__controls');
await expect(navControls).toHaveCSS('font-family', 'monospace');
});
});
// TODO: Troubleshoot flaky suite
describe.skip('sorting', () => {
beforeAll(async () => {
[1, 2].map(async () => {
await createPost();
});
await createPost();
await createPost();
});
test('should sort', async () => {
const getTableItems = () => page.locator(tableRowLocator);
await expect(getTableItems()).toHaveCount(2);
const upChevron = page.locator('#heading-id .sort-column__asc');
const downChevron = page.locator('#heading-id .sort-column__desc');
const getFirstId = async () => page.locator('.row-1 .cell-id').innerText();
const getSecondId = async () => page.locator('.row-2 .cell-id').innerText();
const firstId = await page.locator('.row-1 .cell-id').innerText();
const secondId = await page.locator('.row-2 .cell-id').innerText();
const firstId = await getFirstId();
const secondId = await getSecondId();
await upChevron.click({ delay: 100 });
await upChevron.click({ delay: 200 });
// Order should have swapped
expect(await getFirstId()).toEqual(secondId);
expect(await getSecondId()).toEqual(firstId);
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: 100 });
await downChevron.click({ delay: 200 });
// Swap back
expect(await getFirstId()).toEqual(firstId);
expect(await getSecondId()).toEqual(secondId);
expect(await page.locator('.row-1 .cell-id').innerText()).toEqual(firstId);
expect(await page.locator('.row-2 .cell-id').innerText()).toEqual(secondId);
});
});
});

View File

@@ -6,6 +6,14 @@
*/
export interface Config {}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "global".
*/
export interface Global {
id: string;
title?: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".

6
test/admin/styles.scss Normal file
View File

@@ -0,0 +1,6 @@
.nav__controls {
font-family: monospace;
}
.nav__controls:before {
content: 'custom-css';
}

View File

@@ -12,7 +12,7 @@ export interface Config {}
*/
export interface Array {
id: string;
array?: {
array: {
required: string;
optional?: string;
id?: string;

View File

@@ -12,6 +12,7 @@ export interface Config {}
*/
export interface User {
id: string;
roles: ('admin' | 'editor' | 'moderator' | 'user' | 'viewer')[];
enableAPIKey?: boolean;
apiKey?: string;
apiKeyIndex?: string;
@@ -20,7 +21,6 @@ export interface User {
resetPasswordExpiration?: string;
loginAttempts?: number;
lockUntil?: string;
roles: ('admin' | 'editor' | 'moderator' | 'user' | 'viewer')[];
createdAt: string;
updatedAt: string;
}

View File

@@ -3,9 +3,6 @@ import { Config, SanitizedConfig } from '../src/config/types';
import { buildConfig as buildPayloadConfig } from '../src/config/build';
const baseConfig: Config = {
typescript: {
outputFile: process.env.PAYLOAD_TS_OUTPUT_PATH,
},
telemetry: false,
};

View File

@@ -1,52 +0,0 @@
import { Response } from 'express';
import { CollectionConfig } from '../../../src/collections/config/types';
import { openAccess } from '../../helpers/configHelpers';
import { PayloadRequest } from '../../../src/express/types';
export const endpointsSlug = 'endpoints';
const Endpoints: CollectionConfig = {
slug: endpointsSlug,
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',
},
],
};
export default Endpoints;

View File

@@ -2,7 +2,6 @@ import type { CollectionConfig } from '../../src/collections/config/types';
import { devUser } from '../credentials';
import { buildConfig } from '../buildConfig';
import type { Post } from './payload-types';
import Endpoints from './Endpoints';
export interface Relation {
id: string;
@@ -102,8 +101,13 @@ export default buildConfig({
type: 'text',
},
{
name: 'name',
type: 'text',
type: 'row',
fields: [
{
name: 'name',
type: 'text',
},
],
},
],
},
@@ -121,7 +125,6 @@ export default buildConfig({
},
],
},
Endpoints,
],
onInit: async (payload) => {
await payload.create({
@@ -197,5 +200,21 @@ export default buildConfig({
],
},
});
await payload.create({
collection: customIdSlug,
data: {
id: 'test',
name: 'inside row',
},
});
await payload.create({
collection: customIdNumberSlug,
data: {
id: 123,
name: 'name',
},
});
},
});

View File

@@ -1,38 +0,0 @@
import { initPayloadTest } from '../helpers/configHelpers';
import { endpointsSlug } from './Endpoints';
import { RESTClient } from '../helpers/rest';
import { slug } from '../globals/config';
require('isomorphic-fetch');
let client: RESTClient;
describe('Collections - Endpoints', () => {
beforeAll(async () => {
const config = await initPayloadTest({ __dirname, init: { local: false } });
const { serverURL } = config;
client = new RESTClient(config, { serverURL, defaultSlug: slug });
});
describe('Endpoints', () => {
it('should GET a static endpoint', async () => {
const { status, data } = await client.endpoint(`/${endpointsSlug}/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(`/${endpointsSlug}/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(`/${endpointsSlug}/whoami`, 'post', params);
expect(status).toBe(200);
expect(data.name).toStrictEqual(params.name);
expect(data.age).toStrictEqual(params.age);
});
});
});

View File

@@ -66,13 +66,15 @@ describe('collections-rest', () => {
describe('string', () => {
it('should create', async () => {
const customId = `custom-${randomBytes(32).toString('hex').slice(0, 12)}`;
const { doc } = await client.create({ slug: customIdSlug, data: { id: customId, data: { name: 'custom-id-name' } } });
const customIdName = 'custom-id-name';
const { doc } = await client.create({ slug: customIdSlug, data: { id: customId, name: customIdName } });
expect(doc.id).toEqual(customId);
expect(doc.name).toEqual(customIdName);
});
it('should find', async () => {
const customId = `custom-${randomBytes(32).toString('hex').slice(0, 12)}`;
const { doc } = await client.create({ slug: customIdSlug, data: { id: customId, data: { name: 'custom-id-name' } } });
const { doc } = await client.create({ slug: customIdSlug, data: { id: customId, name: 'custom-id-name' } });
const { doc: foundDoc } = await client.findByID({ slug: customIdSlug, id: customId });
expect(foundDoc.id).toEqual(doc.id);
@@ -89,20 +91,20 @@ describe('collections-rest', () => {
describe('number', () => {
it('should create', async () => {
const customId = Math.floor(Math.random() * (1_000_000)) + 1;
const { doc } = await client.create({ slug: customIdNumberSlug, data: { id: customId, data: { name: 'custom-id-number-name' } } });
const { doc } = await client.create({ slug: customIdNumberSlug, data: { id: customId, name: 'custom-id-number-name' } });
expect(doc.id).toEqual(customId);
});
it('should find', async () => {
const customId = Math.floor(Math.random() * (1_000_000)) + 1;
const { doc } = await client.create({ slug: customIdNumberSlug, data: { id: customId, data: { name: 'custom-id-number-name' } } });
const { doc } = await client.create({ slug: customIdNumberSlug, data: { id: customId, name: 'custom-id-number-name' } });
const { doc: foundDoc } = await client.findByID({ slug: customIdNumberSlug, id: customId });
expect(foundDoc.id).toEqual(doc.id);
});
it('should update', async () => {
const customId = Math.floor(Math.random() * (1_000_000)) + 1;
const { doc } = await client.create({ slug: customIdNumberSlug, data: { id: customId, data: { name: 'custom-id-number-name' } } });
const { doc } = await client.create({ slug: customIdNumberSlug, data: { id: customId, name: 'custom-id-number-name' } });
const { doc: updatedDoc } = await client.update({ slug: customIdNumberSlug, id: doc.id, data: { name: 'updated' } });
expect(updatedDoc.name).toEqual('updated');
});
@@ -371,6 +373,22 @@ describe('collections-rest', () => {
expect(result.totalDocs).toEqual(1);
});
it('like - cyrillic characters', async () => {
const post1 = await createPost({ title: 'Тест' });
const { status, result } = await client.find<Post>({
query: {
title: {
like: 'Тест',
},
},
});
expect(status).toEqual(200);
expect(result.docs).toEqual([post1]);
expect(result.totalDocs).toEqual(1);
});
it('like - partial word match', async () => {
const post = await createPost({ title: 'separate words should partially match' });

View File

@@ -93,16 +93,6 @@ export interface CustomIdNumber {
createdAt: string;
updatedAt: 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".

47
test/dataloader/config.ts Normal file
View File

@@ -0,0 +1,47 @@
import { buildConfig } from '../buildConfig';
import { devUser } from '../credentials';
export default buildConfig({
collections: [
{
slug: 'posts',
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'owner',
type: 'relationship',
relationTo: 'users',
hooks: {
beforeChange: [
({ req: { user } }) => user?.id,
],
},
},
],
},
],
onInit: async (payload) => {
const user = await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
});
await payload.create({
user,
collection: 'posts',
data: postDoc,
});
},
});
export const postDoc = {
title: 'test post',
};

View File

@@ -0,0 +1,53 @@
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);
});
});
});

View File

@@ -0,0 +1,33 @@
/* 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 {}
/**
* 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;
}

90
test/endpoints/config.ts Normal file
View File

@@ -0,0 +1,90 @@
import { Response } from 'express';
import { devUser } from '../credentials';
import { buildConfig } from '../buildConfig';
import { openAccess } from '../helpers/configHelpers';
import { PayloadRequest } from '../../src/express/types';
export const collectionSlug = 'endpoints';
export const globalSlug = 'global-endpoints';
export const globalEndpoint = 'global';
export const applicationEndpoint = 'path';
export default buildConfig({
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',
},
],
},
],
globals: [
{
slug: globalSlug,
endpoints: [{
path: `/${globalEndpoint}`,
method: 'post',
handler: (req: PayloadRequest, res: Response): void => {
res.json(req.body);
},
}],
fields: [],
},
],
endpoints: [
{
path: `/${applicationEndpoint}`,
method: 'post',
handler: (req: PayloadRequest, res: Response): void => {
res.json(req.body);
},
},
],
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
});
},
});

View File

@@ -0,0 +1,58 @@
import { initPayloadTest } from '../helpers/configHelpers';
import { RESTClient } from '../helpers/rest';
import { applicationEndpoint, collectionSlug, globalEndpoint, globalSlug } 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(`/${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(`/${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(`/${collectionSlug}/whoami`, 'post', params);
expect(status).toBe(200);
expect(data.name).toStrictEqual(params.name);
expect(data.age).toStrictEqual(params.age);
});
});
describe('Globals', () => {
it('should call custom endpoint', async () => {
const params = { globals: 'response' };
const { status, data } = await client.endpoint(`/globals/${globalSlug}/${globalEndpoint}`, 'post', params);
expect(status).toBe(200);
expect(params).toMatchObject(data);
});
});
describe('API', () => {
it('should call custom endpoint', async () => {
const params = { app: 'response' };
const { status, data } = await client.endpoint(`/${applicationEndpoint}`, 'post', params);
expect(status).toBe(200);
expect(params).toMatchObject(data);
});
});
});

View File

@@ -0,0 +1,39 @@
/* 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 {}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "global-endpoints".
*/
export interface GlobalEndpoints {
id: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "endpoints".
*/
export interface Endpoint {
id: string;
title?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
loginAttempts?: number;
lockUntil?: string;
createdAt: string;
updatedAt: string;
}

View File

@@ -128,7 +128,7 @@ export default buildConfig({
});
const relationOneIDs = [];
await mapAsync([...Array(5)], async () => {
await mapAsync([...Array(11)], async () => {
const doc = await payload.create<RelationOne>({
collection: relationOneSlug,
data: {
@@ -156,18 +156,22 @@ export default buildConfig({
name: 'relation-restricted',
},
});
const { id: relationWithTitleDocId } = await payload.create<RelationWithTitle>({
collection: relationWithTitleSlug,
data: {
name: 'relation-title',
},
const relationsWithTitle = [];
await mapAsync(['relation-title', 'word boundary search'], async (title) => {
const { id } = await payload.create<RelationWithTitle>({
collection: relationWithTitleSlug,
data: {
name: title,
},
});
relationsWithTitle.push(id);
});
await payload.create<FieldsRelationship>({
collection: slug,
data: {
relationship: relationOneDocId,
relationshipRestricted: restrictedDocId,
relationshipWithTitle: relationWithTitleDocId,
relationshipWithTitle: relationsWithTitle[0],
},
});
await mapAsync([...Array(11)], async () => {

View File

@@ -81,6 +81,14 @@ describe('fields - relationship', () => {
},
});
// Doc with useAsTitle for word boundary test
await payload.create<RelationWithTitle>({
collection: relationWithTitleSlug,
data: {
name: 'word boundary search',
},
});
// Add restricted doc as relation
docWithExistingRelations = await payload.create<CollectionWithRelationships>({
collection: slug,
@@ -190,7 +198,25 @@ describe('fields - relationship', () => {
});
// test.todo('should paginate within the dropdown');
// test.todo('should search within the relationship field');
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-occuring-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));
@@ -203,7 +229,7 @@ describe('fields - relationship', () => {
await field.click({ delay: 100 });
const options = page.locator('.rs__option');
await expect(options).toHaveCount(2); // None + 1 Doc
await expect(options).toHaveCount(3); // None + 2 Doc
});
test('should show id on relation in list view', async () => {

View File

@@ -78,32 +78,6 @@ export interface RelationWithTitle {
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "group-nested-relation-with-title".
*/
export interface GroupNestedRelationWithTitle {
id: string;
group?: {
relation?: string | NestedRelationWithTitle;
};
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "nested-relation-with-title".
*/
export interface NestedRelationWithTitle {
id: string;
group?: {
subGroup?: {
relation?: string | RelationOne;
};
};
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".

View File

@@ -14,6 +14,10 @@ export const blocksField: Field = {
type: 'text',
required: true,
},
{
name: 'richText',
type: 'richText',
},
],
},
{
@@ -63,12 +67,55 @@ export const blocksField: Field = {
},
],
},
{
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',
},
],
},
],
},
],
},
],
},
],
};
const BlockFields: CollectionConfig = {
slug: 'block-fields',
fields: [blocksField],
fields: [
blocksField,
{
...blocksField,
name: 'localizedBlocks',
localized: true,
},
],
};
export const blocksFieldSeedData = [
@@ -76,6 +123,7 @@ export const blocksFieldSeedData = [
blockName: 'First block',
blockType: 'text',
text: 'first block',
richText: [],
},
{
blockName: 'Second block',
@@ -102,6 +150,7 @@ export const blocksFieldSeedData = [
export const blocksDoc = {
blocks: blocksFieldSeedData,
localizedBlocks: blocksFieldSeedData,
};
export default BlockFields;

View File

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

View File

@@ -0,0 +1,63 @@
import type { CollectionConfig } from '../../../../src/collections/config/types';
export const defaultText = 'default-text';
const DateFields: CollectionConfig = {
slug: 'date-fields',
admin: {
useAsTitle: 'date',
},
fields: [
{
name: 'default',
type: 'date',
required: true,
},
{
name: 'timeOnly',
type: 'date',
admin: {
date: {
pickerAppearance: 'timeOnly',
},
},
},
{
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',
dayOnly: '2022-08-11T22:00:00.000+00:00',
dayAndTime: '2022-08-12T10:00:00.052+00:00',
monthOnly: '2022-07-31T22:00:00.000+00:00',
};
export default DateFields;

View File

@@ -34,6 +34,24 @@ const IndexedFields: CollectionConfig = {
},
],
},
{
type: 'collapsible',
label: 'Collapsible',
fields: [
{
name: 'collapsibleLocalizedUnique',
type: 'text',
unique: true,
localized: true,
},
{
name: 'collapsibleTextUnique',
type: 'text',
label: 'collapsibleTextUnique',
unique: true,
},
],
},
],
};

View File

@@ -19,6 +19,7 @@ const PointFields: CollectionConfig = {
name: 'localized',
type: 'point',
label: 'Localized Point',
unique: true,
localized: true,
},
{
@@ -36,7 +37,7 @@ const PointFields: CollectionConfig = {
export const pointDoc = {
point: [7, -7],
localized: [5, -2],
localized: [15, -12],
group: { point: [1, 9] },
};

View File

@@ -43,6 +43,22 @@ const RichTextFields: CollectionConfig = {
type: 'richText',
required: true,
admin: {
link: {
fields: [
{
name: 'rel',
label: 'Rel Attribute',
type: 'select',
hasMany: true,
options: [
'noopener', 'noreferrer', 'nofollow',
],
admin: {
description: 'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
},
},
],
},
upload: {
collections: {
uploads: {
@@ -78,7 +94,7 @@ export const richTextDoc = {
},
{
type: 'link',
url: 'test.com',
url: 'https://payloadcms.com',
newTab: true,
children: [
{
@@ -87,7 +103,24 @@ export const richTextDoc = {
],
},
{
text: ' and store nested relationship fields:',
text: ', ',
},
{
type: 'link',
linkType: 'internal',
doc: {
value: '{{ARRAY_DOC_ID}}',
relationTo: 'array-fields',
},
fields: {},
children: [
{
text: 'link to relationships',
},
],
},
{
text: ', and store nested relationship fields:',
},
],
},

View File

@@ -30,6 +30,7 @@ const SelectFields: CollectionConfig = {
type: 'select',
admin: {
isClearable: true,
isSortable: true,
},
options: [
{

View File

@@ -13,6 +13,11 @@ const TextFields: CollectionConfig = {
type: 'text',
required: true,
},
{
name: 'localizedText',
type: 'text',
localized: true,
},
{
name: 'defaultFunction',
type: 'text',
@@ -32,6 +37,7 @@ const TextFields: CollectionConfig = {
export const textDoc = {
text: 'Seeded text document',
localizedText: 'Localized text',
};
export default TextFields;

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import path from 'path';
import fs from 'fs';
import { buildConfig } from '../buildConfig';
@@ -6,6 +7,7 @@ import ArrayFields, { arrayDoc } from './collections/Array';
import BlockFields, { blocksDoc } from './collections/Blocks';
import CollapsibleFields, { collapsibleDoc } from './collections/Collapsible';
import ConditionalLogic, { conditionalLogicDoc } from './collections/ConditionalLogic';
import DateFields, { dateDoc } from './collections/Date';
import RichTextFields, { richTextDoc } from './collections/RichText';
import SelectFields, { selectsDoc } from './collections/Select';
import TabsFields, { tabsDoc } from './collections/Tabs';
@@ -16,6 +18,7 @@ import getFileByPath from '../../src/uploads/getFileByPath';
import Uploads, { uploadsDoc } from './collections/Upload';
import IndexedFields from './collections/Indexed';
import NumberFields, { numberDoc } from './collections/Number';
import CodeFields, { codeDoc } from './collections/Code';
export default buildConfig({
admin: {
@@ -33,6 +36,7 @@ export default buildConfig({
collections: [
ArrayFields,
BlockFields,
CodeFields,
CollapsibleFields,
ConditionalLogic,
GroupFields,
@@ -44,6 +48,7 @@ export default buildConfig({
NumberFields,
Uploads,
IndexedFields,
DateFields,
],
localization: {
defaultLocale: 'en',
@@ -58,14 +63,15 @@ export default buildConfig({
},
});
await payload.create({ collection: 'array-fields', data: arrayDoc });
await payload.create({ collection: 'block-fields', data: blocksDoc });
const createdArrayDoc = await payload.create({ collection: 'array-fields', data: arrayDoc });
await payload.create({ collection: 'collapsible-fields', data: collapsibleDoc });
await payload.create({ collection: 'conditional-logic', data: conditionalLogicDoc });
await payload.create({ collection: 'group-fields', data: groupDoc });
await payload.create({ collection: 'select-fields', data: selectsDoc });
await payload.create({ collection: 'tabs-fields', data: tabsDoc });
await payload.create({ collection: 'point-fields', data: pointDoc });
await payload.create({ collection: 'date-fields', data: dateDoc });
await payload.create({ collection: 'code-fields', data: codeDoc });
const createdTextDoc = await payload.create({ collection: 'text-fields', data: textDoc });
@@ -78,7 +84,8 @@ export default buildConfig({
const createdUploadDoc = await payload.create({ collection: 'uploads', data: uploadsDoc, file });
const richTextDocWithRelationship = { ...richTextDoc };
const richTextDocWithRelId = JSON.parse(JSON.stringify(richTextDoc).replace('{{ARRAY_DOC_ID}}', createdArrayDoc.id));
const richTextDocWithRelationship = { ...richTextDocWithRelId };
const richTextRelationshipIndex = richTextDocWithRelationship.richText.findIndex(({ type }) => type === 'relationship');
richTextDocWithRelationship.richText[richTextRelationshipIndex].value = { id: createdTextDoc.id };
@@ -89,5 +96,14 @@ export default buildConfig({
await payload.create({ collection: 'rich-text-fields', data: richTextDocWithRelationship });
await payload.create({ collection: 'number-fields', data: numberDoc });
const blocksDocWithRichText = { ...blocksDoc };
// @ts-ignore
blocksDocWithRichText.blocks[0].richText = richTextDocWithRelationship.richText;
// @ts-ignore
blocksDocWithRichText.localizedBlocks[0].richText = richTextDocWithRelationship.richText;
await payload.create({ collection: 'block-fields', data: blocksDocWithRichText });
},
});

View File

@@ -139,4 +139,75 @@ describe('fields', () => {
await expect(page.locator('#field-textInRow')).toHaveValue(textInRowValue);
});
});
describe('fields - richText', () => {
test('should create new url link', async () => {
const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'rich-text-fields');
await page.goto(url.list);
await page.locator('.row-1 .cell-id').click();
// Open link popup
await page.locator('.rich-text__toolbar .link').click();
const editLinkModal = page.locator('.rich-text-link-edit-modal__template');
await expect(editLinkModal).toBeVisible();
// Fill values and click Confirm
await editLinkModal.locator('#field-text').fill('link text');
await editLinkModal.locator('label[for="field-linkType-custom"]').click();
await editLinkModal.locator('#field-url').fill('https://payloadcms.com');
await wait(200);
await editLinkModal.locator('button[type="submit"]').click();
// Remove link
await page.locator('span >> text="link text"').click();
const popup = page.locator('.popup--active .rich-text-link__popup');
await expect(popup.locator('.rich-text-link__link-label')).toBeVisible();
await popup.locator('.rich-text-link__link-close').click();
await expect(page.locator('span >> text="link text"')).toHaveCount(0);
});
test('should populate url link', async () => {
const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'rich-text-fields');
await page.goto(url.list);
await page.locator('.row-1 .cell-id').click();
// Open link popup
await page.locator('span >> text="render links"').click();
const popup = page.locator('.popup--active .rich-text-link__popup');
await expect(popup).toBeVisible();
await expect(popup.locator('a')).toHaveAttribute('href', 'https://payloadcms.com');
// Open link edit modal
await popup.locator('.rich-text-link__link-edit').click();
const editLinkModal = page.locator('.rich-text-link-edit-modal__template');
await expect(editLinkModal).toBeVisible();
// Close link edit modal
await editLinkModal.locator('button[type="submit"]').click();
await expect(editLinkModal).not.toBeVisible();
});
test('should populate relationship link', async () => {
const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'rich-text-fields');
await page.goto(url.list);
await page.locator('.row-1 .cell-id').click();
// Open link popup
await page.locator('span >> text="link to relationships"').click();
const popup = page.locator('.popup--active .rich-text-link__popup');
await expect(popup).toBeVisible();
await expect(popup.locator('a')).toHaveAttribute('href', /\/admin\/collections\/array-fields\/.*/);
// Open link edit modal
await popup.locator('.rich-text-link__link-edit').click();
const editLinkModal = page.locator('.rich-text-link-edit-modal__template');
await expect(editLinkModal).toBeVisible();
// Close link edit modal
await editLinkModal.locator('button[type="submit"]').click();
await expect(editLinkModal).not.toBeVisible();
// await page.locator('span >> text="render links"').click();
});
});
});

View File

@@ -120,6 +120,9 @@ describe('Fields', () => {
const options: Record<string, IndexOptions> = {};
beforeAll(() => {
// mongoose model schema indexes do not always create indexes in the actual database
// see: https://github.com/payloadcms/payload/issues/571
indexes = payload.collections['indexed-fields'].Model.schema.indexes() as [Record<string, IndexDirection>, IndexOptions];
indexes.forEach((index) => {
@@ -149,10 +152,19 @@ describe('Fields', () => {
expect(definitions['group.localizedUnique.es']).toEqual(1);
expect(options['group.localizedUnique.es']).toMatchObject({ unique: true, sparse: true });
});
it('should have unique indexes in a collapsible', () => {
expect(definitions['collapsibleLocalizedUnique.en']).toEqual(1);
expect(options['collapsibleLocalizedUnique.en']).toMatchObject({ unique: true, sparse: true });
expect(definitions.collapsibleTextUnique).toEqual(1);
expect(options.collapsibleTextUnique).toMatchObject({ unique: true });
});
});
describe('point', () => {
let doc;
const point = [7, -7];
const localized = [5, -2];
const group = { point: [1, 9] };
beforeAll(async () => {
const findDoc = await payload.find({
@@ -176,9 +188,6 @@ describe('Fields', () => {
});
it('should create', async () => {
const point = [7, -7];
const localized = [5, -2];
const group = { point: [1, 9] };
doc = await payload.create({
collection: 'point-fields',
data: {
@@ -192,6 +201,30 @@ describe('Fields', () => {
expect(doc.localized).toEqual(localized);
expect(doc.group).toMatchObject(group);
});
it('should not create duplicate point when unique', async () => {
await expect(() => payload.create({
collection: 'point-fields',
data: {
point,
localized,
group,
},
}))
.rejects
.toThrow(Error);
await expect(async () => payload.create({
collection: 'number-fields',
data: {
min: 5,
},
})).rejects.toThrow('The following field is invalid: min');
expect(doc.point).toEqual(point);
expect(doc.localized).toEqual(localized);
expect(doc.group).toMatchObject(group);
});
});
describe('array', () => {
let doc;
@@ -359,6 +392,78 @@ describe('Fields', () => {
expect(blockFields.docs[0].blocks[2].subBlocks[0].number).toEqual(blocksFieldSeedData[2].subBlocks[0].number);
expect(blockFields.docs[0].blocks[2].subBlocks[1].text).toEqual(blocksFieldSeedData[2].subBlocks[1].text);
});
it('should query based on richtext data within a block', async () => {
const blockFieldsSuccess = await payload.find({
collection: 'block-fields',
where: {
'blocks.richText.children.text': {
like: 'fun',
},
},
});
expect(blockFieldsSuccess.docs).toHaveLength(1);
const blockFieldsFail = await payload.find({
collection: 'block-fields',
where: {
'blocks.richText.children.text': {
like: 'funny',
},
},
});
expect(blockFieldsFail.docs).toHaveLength(0);
});
it('should query based on richtext data within a localized block, specifying locale', async () => {
const blockFieldsSuccess = await payload.find({
collection: 'block-fields',
where: {
'localizedBlocks.en.richText.children.text': {
like: 'fun',
},
},
});
expect(blockFieldsSuccess.docs).toHaveLength(1);
const blockFieldsFail = await payload.find({
collection: 'block-fields',
where: {
'localizedBlocks.en.richText.children.text': {
like: 'funny',
},
},
});
expect(blockFieldsFail.docs).toHaveLength(0);
});
it('should query based on richtext data within a localized block, without specifying locale', async () => {
const blockFieldsSuccess = await payload.find({
collection: 'block-fields',
where: {
'localizedBlocks.richText.children.text': {
like: 'fun',
},
},
});
expect(blockFieldsSuccess.docs).toHaveLength(1);
const blockFieldsFail = await payload.find({
collection: 'block-fields',
where: {
'localizedBlocks.richText.children.text': {
like: 'funny',
},
},
});
expect(blockFieldsFail.docs).toHaveLength(0);
});
});
describe('richText', () => {
@@ -385,5 +490,28 @@ describe('Fields', () => {
expect(workingRichTextQuery.docs).toHaveLength(1);
});
it('should populate link relationship', async () => {
const query = await payload.find({
collection: 'rich-text-fields',
where: {
'richText.children.linkType': {
equals: 'internal',
},
},
});
const nodes = query.docs[0].richText;
expect(nodes).toBeDefined();
const child = nodes.flatMap((n) => n.children)
.find((c) => c.doc);
expect(child).toMatchObject({
type: 'link',
linkType: 'internal',
});
expect(child.doc.relationTo).toEqual('array-fields');
expect(typeof child.doc.value.id).toBe('string');
expect(child.doc.value.items).toHaveLength(6);
});
});
});

View File

@@ -40,6 +40,9 @@ export interface BlockField {
blocks: (
| {
text: string;
richText?: {
[k: string]: unknown;
}[];
id?: string;
blockName?: string;
blockType: 'text';
@@ -70,6 +73,56 @@ export interface BlockField {
blockType: 'subBlocks';
}
)[];
localizedBlocks: (
| {
text: string;
richText?: {
[k: string]: unknown;
}[];
id?: string;
blockName?: string;
blockType: 'text';
}
| {
number: number;
id?: string;
blockName?: string;
blockType: 'number';
}
| {
subBlocks: (
| {
text: string;
id?: string;
blockName?: string;
blockType: 'text';
}
| {
number: number;
id?: string;
blockName?: string;
blockType: 'number';
}
)[];
id?: string;
blockName?: string;
blockType: 'subBlocks';
}
)[];
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "code-fields".
*/
export interface CodeField {
id: string;
javascript?: string;
typescript?: string;
json?: string;
html?: string;
css?: string;
createdAt: string;
updatedAt: string;
}
@@ -188,6 +241,9 @@ export interface TabsField {
blocks: (
| {
text: string;
richText?: {
[k: string]: unknown;
}[];
id?: string;
blockName?: string;
blockType: 'text';
@@ -235,6 +291,7 @@ export interface TabsField {
export interface TextField {
id: string;
text: string;
localizedText?: string;
defaultFunction?: string;
defaultAsync?: string;
createdAt: string;
@@ -292,6 +349,22 @@ export interface IndexedField {
*/
point?: [number, number];
};
collapsibleLocalizedUnique?: string;
collapsibleTextUnique?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "date-fields".
*/
export interface DateField {
id: string;
default: string;
timeOnly?: string;
dayOnly?: string;
dayAndTime?: string;
monthOnly?: string;
createdAt: string;
updatedAt: string;
}

View File

@@ -1,7 +1,5 @@
import { Response } from 'express';
import { devUser } from '../credentials';
import { buildConfig } from '../buildConfig';
import { PayloadRequest } from '../../src/express/types';
export const slug = 'global';
export const arraySlug = 'array';
@@ -31,13 +29,6 @@ export default buildConfig({
type: 'text',
},
],
endpoints: [{
path: `/${globalsEndpoint}`,
method: 'post',
handler: (req: PayloadRequest, res: Response): void => {
res.json(req.body);
},
}],
},
{
slug: arraySlug,

View File

@@ -1,6 +1,6 @@
import { GraphQLClient } from 'graphql-request';
import { initPayloadTest } from '../helpers/configHelpers';
import config, { arraySlug, englishLocale, globalsEndpoint, slug, spanishLocale } from './config';
import config, { arraySlug, englishLocale, slug, spanishLocale } from './config';
import payload from '../../src';
import { RESTClient } from '../helpers/rest';
@@ -56,16 +56,6 @@ describe('globals', () => {
expect(doc.array).toMatchObject(array);
expect(doc.id).toBeDefined();
});
describe('Endpoints', () => {
it('should call custom endpoint', async () => {
const params = { globals: 'response' };
const { status, data } = await client.endpoint(`/globals/${slug}/${globalsEndpoint}`, 'post', params);
expect(status).toBe(200);
expect(params).toMatchObject(data);
});
});
});
describe('local', () => {

View File

@@ -0,0 +1,41 @@
/* 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 {}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "global".
*/
export interface Global {
id: string;
title?: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "array".
*/
export interface Array {
id: string;
array: {
text?: string;
id?: string;
}[];
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
loginAttempts?: number;
lockUntil?: string;
createdAt: string;
updatedAt: string;
}

View File

@@ -174,7 +174,7 @@ export class RESTClient {
const response = await fetch(`${this.serverURL}/api/${slug || this.defaultSlug}/${id}${formattedQs}`, {
body: JSON.stringify(data),
headers,
method: 'put',
method: 'PATCH',
});
const { status } = response;
const json = await response.json();

View File

@@ -1,11 +1,15 @@
/* eslint-disable no-param-reassign */
import { CollectionConfig } from '../../../../src/collections/config/types';
import { openAccess } from '../../../helpers/configHelpers';
export const hooksSlug = 'hooks';
const Hooks: CollectionConfig = {
slug: hooksSlug,
access: openAccess,
access: {
read: () => true,
create: () => true,
delete: () => true,
update: () => true,
},
hooks: {
beforeValidate: [({ data }) => validateHookOrder('collectionBeforeValidate', data)],
beforeChange: [({ data }) => validateHookOrder('collectionBeforeChange', data)],

View File

@@ -1,6 +1,5 @@
/* eslint-disable no-param-reassign */
import { CollectionConfig } from '../../../../src/collections/config/types';
import { openAccess } from '../../../helpers/configHelpers';
const validateFieldTransformAction = (hook: string, value) => {
if (value !== undefined && value !== null && !Array.isArray(value)) {
@@ -12,7 +11,12 @@ const validateFieldTransformAction = (hook: string, value) => {
export const transformSlug = 'transforms';
const TransformHooks: CollectionConfig = {
slug: transformSlug,
access: openAccess,
access: {
read: () => true,
create: () => true,
delete: () => true,
update: () => true,
},
fields: [
{
name: 'transform',

View File

@@ -1,11 +1,13 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import payload from '../../src';
import type { TypeWithTimestamps } from '../../src/collections/config/types';
import { AdminUrlUtil } from '../helpers/adminUrlUtil';
import { initPayloadTest } from '../helpers/configHelpers';
import { login, saveDocAndAssert } from '../helpers';
import type { LocalizedPost } from './payload-types';
import { slug } from './config';
import { englishTitle, spanishLocale } from './shared';
/**
* TODO: Localization
@@ -93,6 +95,40 @@ describe('Localization', () => {
await expect(page.locator('#field-description')).toHaveValue(description);
});
});
describe('localized duplicate', () => {
let id;
beforeAll(async () => {
const localizedPost = await payload.create<LocalizedPost>({
collection: slug,
data: {
title: englishTitle,
},
});
id = localizedPost.id;
await payload.update<LocalizedPost>({
collection: slug,
id,
locale: spanishLocale,
data: {
title: spanishTitle,
},
});
});
test('should duplicate data for all locales', async () => {
await page.goto(url.edit(id));
await page.locator('.btn.duplicate').first().click();
await expect(page.locator('.Toastify')).toContainText('successfully');
await expect(page.locator('#field-title')).toHaveValue(englishTitle);
await changeLocale(spanishLocale);
await expect(page.locator('#field-title')).toHaveValue(spanishTitle);
});
});
});
async function fillValues(data: Partial<Omit<LocalizedPost, keyof TypeWithTimestamps>>) {

View File

@@ -230,7 +230,7 @@ describe('Localization', () => {
const result = await payload.find<WithLocalizedRelationship>({
collection: withLocalizedRelSlug,
where: {
'localizedRelation.title': {
'localizedRelationship.title': {
equals: localizedRelation.title,
},
},
@@ -244,7 +244,7 @@ describe('Localization', () => {
collection: withLocalizedRelSlug,
locale: spanishLocale,
where: {
'localizedRelation.title': {
'localizedRelationship.title': {
equals: relationSpanishTitle,
},
},
@@ -258,7 +258,7 @@ describe('Localization', () => {
collection: withLocalizedRelSlug,
locale: 'all',
where: {
'localizedRelation.title.es': {
'localizedRelationship.title.es': {
equals: relationSpanishTitle,
},
},
@@ -561,6 +561,55 @@ describe('Localization', () => {
expect(typeof result.user.relation.title).toStrictEqual('string');
});
it('should create and update collections', async () => {
const url = `${serverURL}${config.routes.api}${config.routes.graphQL}`;
const client = new GraphQLClient(url);
const create = `mutation {
createLocalizedPost(
data: {
title: "${englishTitle}"
}
locale: ${defaultLocale}
) {
id
title
}
}`;
const { createLocalizedPost: createResult } = await client.request(create, null, {
Authorization: `JWT ${token}`,
});
const update = `mutation {
updateLocalizedPost(
id: "${createResult.id}",
data: {
title: "${spanishTitle}"
}
locale: ${spanishLocale}
) {
title
}
}`;
const { updateLocalizedPost: updateResult } = await client.request(update, null, {
Authorization: `JWT ${token}`,
});
const result = await payload.findByID({
collection: slug,
id: createResult.id,
locale: 'all',
});
expect(createResult.title).toStrictEqual(englishTitle);
expect(updateResult.title).toStrictEqual(spanishTitle);
expect(result.title[defaultLocale]).toStrictEqual(englishTitle);
expect(result.title[spanishLocale]).toStrictEqual(spanishTitle);
});
});
});

View File

@@ -6,6 +6,21 @@
*/
export interface Config {}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
relation?: string | LocalizedPost;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
loginAttempts?: number;
lockUntil?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localized-posts".
@@ -81,17 +96,3 @@ export interface Dummy {
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
loginAttempts?: number;
lockUntil?: string;
createdAt: string;
updatedAt: string;
}

View File

@@ -168,6 +168,35 @@ describe('Relationships', () => {
const { doc } = await client.findByID<Post>({ id: post.id });
expect(doc?.customIdNumberRelation).toMatchObject({ id: generatedCustomIdNumber });
});
it('should validate the format of text id relationships', async () => {
await expect(async () => createPost({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Sending bad data to test error handling
customIdRelation: 1234,
})).rejects.toThrow('The following field is invalid: customIdRelation');
});
it('should validate the format of number id relationships', async () => {
await expect(async () => createPost({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Sending bad data to test error handling
customIdNumberRelation: 'bad-input',
})).rejects.toThrow('The following field is invalid: customIdNumberRelation');
});
it('should allow update removing a relationship', async () => {
const result = await client.update<Post>({
slug,
id: post.id,
data: {
relationField: null,
},
});
expect(result.status).toEqual(200);
expect(result.doc.relationField).toBeNull();
});
});
describe('depth', () => {

View File

@@ -4,7 +4,7 @@ import FormData from 'form-data';
import { promisify } from 'util';
import { initPayloadTest } from '../helpers/configHelpers';
import { RESTClient } from '../helpers/rest';
import config, { mediaSlug } from './config';
import config, { mediaSlug, relationSlug } from './config';
import payload from '../../src';
import getFileByPath from '../../src/uploads/getFileByPath';
@@ -133,6 +133,35 @@ describe('Collections - Uploads', () => {
expect(await fileExists(path.join(__dirname, './media', mediaDoc.sizes.icon.filename))).toBe(true);
});
it('should allow update removing a relationship', async () => {
const filePath = path.resolve(__dirname, './image.png');
const file = getFileByPath(filePath);
file.name = 'renamed.png';
const { id } = await payload.create({
collection: mediaSlug,
data: {},
file,
});
const related = await payload.create({
collection: relationSlug,
data: {
image: id,
},
});
const doc = await payload.update({
collection: relationSlug,
id: related.id,
data: {
image: null,
},
});
expect(doc.image).toBeNull();
});
it('delete', async () => {
const formData = new FormData();
formData.append('file', fs.createReadStream(path.join(__dirname, './image.png')));

View File

@@ -28,8 +28,8 @@ export interface Media {
filesize?: number;
width?: number;
height?: number;
sizes?: {
maintainedAspectRatio?: {
sizes: {
maintainedAspectRatio: {
url?: string;
width?: number;
height?: number;
@@ -37,7 +37,7 @@ export interface Media {
filesize?: number;
filename?: string;
};
tablet?: {
tablet: {
url?: string;
width?: number;
height?: number;
@@ -45,7 +45,7 @@ export interface Media {
filesize?: number;
filename?: string;
};
mobile?: {
mobile: {
url?: string;
width?: number;
height?: number;
@@ -53,7 +53,7 @@ export interface Media {
filesize?: number;
filename?: string;
};
icon?: {
icon: {
url?: string;
width?: number;
height?: number;
@@ -65,6 +65,19 @@ export interface Media {
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "unstored-media".
*/
export interface UnstoredMedia {
id: string;
url?: string;
filename?: string;
mimeType?: string;
filesize?: number;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".

View File

@@ -62,7 +62,7 @@ describe('Versions', () => {
collectionLocalPostID = autosavePost.id;
const updatedPost = await payload.update<any>({
const updatedPost = await payload.update({
id: collectionLocalPostID,
collection,
data: {
@@ -82,6 +82,35 @@ describe('Versions', () => {
expect(collectionLocalVersionID).toBeDefined();
});
it('should allow saving multiple versions of models with unique fields', async () => {
const autosavePost = await payload.create({
collection,
data: {
title: 'unique unchanging title',
description: 'description 1',
},
});
await payload.update({
id: autosavePost.id,
collection,
data: {
description: 'description 2',
},
});
const finalDescription = 'final description';
const secondUpdate = await payload.update({
id: autosavePost.id,
collection,
data: {
description: finalDescription,
},
});
expect(secondUpdate.description).toBe(finalDescription);
});
it('should allow a version to be retrieved by ID', async () => {
const version = await payload.findVersionByID({
collection,

View File

@@ -8,10 +8,34 @@
export interface Config {}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "slugname".
* via the `definition` "autosave-global".
*/
export interface Slugname {
export interface AutosaveGlobal {
id: string;
title: string;
_status?: 'draft' | 'published';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-posts".
*/
export interface AutosavePost {
id: string;
title: string;
description: string;
_status?: 'draft' | 'published';
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "draft-posts".
*/
export interface DraftPost {
id: string;
title: string;
description: string;
_status?: 'draft' | 'published';
createdAt: string;
updatedAt: string;
}