Merge branch 'master' of github.com:payloadcms/payload into feat/form-onchange

This commit is contained in:
Jacob Fletcher
2021-11-24 14:25:08 -05:00
27 changed files with 1552 additions and 65 deletions

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { useAuth } from '@payloadcms/config-provider';
import Button from '../Button';
import { Props } from './types';
@@ -6,13 +6,34 @@ import { useLocale } from '../../utilities/Locale';
const baseClass = 'preview-btn';
const PreviewButton: React.FC<Props> = ({ generatePreviewURL, data }) => {
const PreviewButton: React.FC<Props> = (props) => {
const {
generatePreviewURL,
data
} = props;
const [url, setUrl] = useState<string | undefined>(undefined);
const locale = useLocale();
const { token } = useAuth();
if (generatePreviewURL && typeof generatePreviewURL === 'function') {
const url = generatePreviewURL(data, { locale, token });
useEffect(() => {
if (generatePreviewURL && typeof generatePreviewURL === 'function') {
const makeRequest = async () => {
const previewURL = await generatePreviewURL(data, { locale, token });
setUrl(previewURL);
}
makeRequest();
}
}, [
generatePreviewURL,
locale,
token,
data
]);
if (url) {
return (
<Button
el="anchor"

View File

@@ -2,9 +2,9 @@ import React from 'react';
export type DescriptionFunction = (value: unknown) => string
export type DescriptionComponent = React.ComponentType<{value: unknown}>
export type DescriptionComponent = React.ComponentType<{ value: unknown }>
type Description = string | DescriptionFunction | DescriptionComponent
export type Description = string | DescriptionFunction | DescriptionComponent
export type Props = {
description?: Description

View File

@@ -1,7 +1,8 @@
import { Data } from '../../Form/types';
import { ArrayField, Labels, Field, Description } from '../../../../../fields/config/types';
import { ArrayField, Labels, Field } from '../../../../../fields/config/types';
import { FieldTypes } from '..';
import { FieldPermissions } from '../../../../../auth/types';
import { Description } from '../../FieldDescription/types';
export type Props = Omit<ArrayField, 'type'> & {
path?: string

View File

@@ -1,7 +1,8 @@
import { Data } from '../../Form/types';
import { BlockField, Labels, Block, Description } from '../../../../../fields/config/types';
import { BlockField, Labels, Block } from '../../../../../fields/config/types';
import { FieldTypes } from '..';
import { FieldPermissions } from '../../../../../auth/types';
import { Description } from '../../FieldDescription/types';
export type Props = Omit<BlockField, 'type'> & {
path?: string

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Description, Validate } from '../../../../../fields/config/types';
import { Validate } from '../../../../../fields/config/types';
import { Description } from '../../FieldDescription/types';
export type Props = {
autoComplete?: string

375
src/bin/generateTypes.ts Normal file
View File

@@ -0,0 +1,375 @@
/* eslint-disable no-nested-ternary */
import fs from 'fs';
import type { JSONSchema4 } from 'json-schema';
import { compile } from 'json-schema-to-typescript';
import payload from '..';
import { fieldAffectsData, Field, Option, FieldAffectingData } from '../fields/config/types';
import { SanitizedCollectionConfig } from '../collections/config/types';
import { SanitizedConfig } from '../config/types';
import loadConfig from '../config/load';
import { SanitizedGlobalConfig } from '../globals/config/types';
function getCollectionIDType(collections: SanitizedCollectionConfig[], slug: string): 'string' | 'number' {
const matchedCollection = collections.find((collection) => collection.slug === slug);
const customIdField = matchedCollection.fields.find((field) => 'name' in field && field.name === 'id');
if (customIdField && customIdField.type === 'number') {
return 'number';
}
return 'string';
}
function returnOptionEnums(options: Option[]): string[] {
return options.map((option) => {
if (typeof option === 'object' && 'value' in option) {
return option.value;
}
return option;
});
}
function generateFieldTypes(config: SanitizedConfig, fields: Field[]): {
properties: {
[k: string]: JSONSchema4;
}
required: string[]
} {
let topLevelProps = [];
let requiredTopLevelProps = [];
return {
properties: Object.fromEntries(
fields.reduce((properties, field) => {
let fieldSchema: JSONSchema4;
switch (field.type) {
case 'text':
case 'textarea':
case 'code':
case 'email':
case 'date': {
fieldSchema = { type: 'string' };
break;
}
case 'number': {
fieldSchema = { type: 'number' };
break;
}
case 'checkbox': {
fieldSchema = { type: 'boolean' };
break;
}
case 'richText': {
fieldSchema = {
type: 'array',
items: {
type: 'object',
},
};
break;
}
case 'radio': {
fieldSchema = {
type: 'string',
enum: returnOptionEnums(field.options),
};
break;
}
case 'select': {
const selectType: JSONSchema4 = {
type: 'string',
enum: returnOptionEnums(field.options),
};
if (field.hasMany) {
fieldSchema = {
type: 'array',
items: selectType,
};
} else {
fieldSchema = selectType;
}
break;
}
case 'point': {
fieldSchema = {
type: 'array',
minItems: 2,
maxItems: 2,
items: [
{
type: 'number',
},
{
type: 'number',
},
],
};
break;
}
case 'relationship': {
if (Array.isArray(field.relationTo)) {
if (field.hasMany) {
fieldSchema = {
type: 'array',
items: {
oneOf: field.relationTo.map((relation) => {
const idFieldType = getCollectionIDType(config.collections, relation);
return {
type: 'object',
additionalProperties: false,
properties: {
value: {
oneOf: [
{
type: idFieldType,
},
{
$ref: `#/definitions/${relation}`,
},
],
},
relationTo: {
const: relation,
},
},
required: ['value', 'relationTo'],
};
}),
},
};
} else {
fieldSchema = {
oneOf: field.relationTo.map((relation) => {
const idFieldType = getCollectionIDType(config.collections, relation);
return {
type: 'object',
additionalProperties: false,
properties: {
value: {
oneOf: [
{
type: idFieldType,
},
{
$ref: `#/definitions/${relation}`,
},
],
},
relationTo: {
const: relation,
},
},
required: ['value', 'relationTo'],
};
}),
};
}
} else {
const idFieldType = getCollectionIDType(config.collections, field.relationTo);
if (field.hasMany) {
fieldSchema = {
type: 'array',
items: {
oneOf: [
{
type: idFieldType,
},
{
$ref: `#/definitions/${field.relationTo}`,
},
],
},
};
} else {
fieldSchema = {
oneOf: [
{
type: idFieldType,
},
{
$ref: `#/definitions/${field.relationTo}`,
},
],
};
}
}
break;
}
case 'upload': {
const idFieldType = getCollectionIDType(config.collections, field.relationTo);
fieldSchema = {
oneOf: [
{
type: idFieldType,
},
{
$ref: `#/definitions/${field.relationTo}`,
},
],
};
break;
}
case 'blocks': {
fieldSchema = {
type: 'array',
items: {
oneOf: field.blocks.map((block) => {
const blockSchema = generateFieldTypes(config, block.fields);
return {
type: 'object',
additionalProperties: false,
properties: {
...blockSchema.properties,
blockType: {
const: block.slug,
},
},
required: [
'blockType',
...blockSchema.required,
],
};
}),
},
};
break;
}
case 'array': {
fieldSchema = {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
...generateFieldTypes(config, field.fields),
},
};
break;
}
case 'row': {
const topLevelFields = generateFieldTypes(config, field.fields);
requiredTopLevelProps = requiredTopLevelProps.concat(topLevelFields.required);
topLevelProps = topLevelProps.concat(Object.entries(topLevelFields.properties).map((prop) => prop));
break;
}
case 'group': {
fieldSchema = {
type: 'object',
additionalProperties: false,
...generateFieldTypes(config, field.fields),
};
break;
}
default: {
break;
}
}
if (fieldSchema && fieldAffectsData(field)) {
return [
...properties,
[
field.name,
{
...fieldSchema,
},
],
];
}
return [
...properties,
...topLevelProps,
];
}, []),
),
required: [
...fields
.filter((field) => fieldAffectsData(field) && field.required === true)
.map((field) => (fieldAffectsData(field) ? field.name : '')),
...requiredTopLevelProps,
],
};
}
function entityToJsonSchema(config: SanitizedConfig, entity: SanitizedCollectionConfig | SanitizedGlobalConfig): JSONSchema4 {
const title = 'label' in entity ? entity.label : entity.labels.singular;
const idField: FieldAffectingData = { type: 'text', name: 'id', required: true };
const customIdField = entity.fields.find((field) => fieldAffectsData(field) && field.name === 'id') as FieldAffectingData;
if (customIdField) {
customIdField.required = true;
} else {
entity.fields.unshift(idField);
}
return {
title,
type: 'object',
additionalProperties: false,
...generateFieldTypes(config, entity.fields),
};
}
function configToJsonSchema(config: SanitizedConfig): JSONSchema4 {
return {
definitions: Object.fromEntries(
[
...config.globals.map((global) => [
global.slug,
entityToJsonSchema(config, global),
]),
...config.collections.map((collection) => [
collection.slug,
entityToJsonSchema(config, collection),
]),
],
),
additionalProperties: false,
};
}
export function generateTypes(): void {
const config = loadConfig();
payload.logger.info('Compiling TS types for Collections and Globals...');
const jsonSchema = configToJsonSchema(config);
compile(jsonSchema, 'Config', {
unreachableDefinitions: true,
}).then((compiled) => {
fs.writeFileSync(config.typescript.outputFile, compiled);
payload.logger.info(`Types written to ${config.typescript.outputFile}`);
});
}
// when generateTypes.js is launched directly
if (module.id === require.main.id) {
generateTypes();
}

View File

@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import minimist from 'minimist';
import { generateTypes } from './generateTypes';
import babelConfig from '../babel.config';
require('@babel/register')({
@@ -23,6 +24,12 @@ switch (script) {
break;
}
case 'generate:types': {
generateTypes();
break;
}
default:
console.log(`Unknown script "${script}".`);
break;

View File

@@ -98,22 +98,28 @@ async function find(incomingArgs: Arguments): Promise<PaginatedDocs> {
// Find
// /////////////////////////////////////
let { sort } = args;
let sortParam: Record<string, string>;
if (!sort) {
if (!args.sort) {
if (collectionConfig.timestamps) {
sort = '-createdAt';
sortParam = { createdAt: 'desc' };
} else {
sort = '-_id';
sortParam = { _id: 'desc' };
}
} else if (sort === 'id' || sort === '-id') {
sort = sort.replace('id', '_id');
} else if (args.sort.indexOf('-') === 0) {
sortParam = {
[args.sort.substring(1)]: 'desc',
};
} else {
sortParam = {
[args.sort]: 'asc',
};
}
const optionsToExecute = {
page: page || 1,
limit: limit || 10,
sort,
sort: sortParam,
lean: true,
leanWithId: true,
useEstimatedCount,

View File

@@ -430,12 +430,74 @@ describe('Collections - REST', () => {
const data1 = await queryRes1.json();
expect(data1.docs).toHaveLength(1);
});
const queryRes2 = await fetch(`${url}/api/relationship-a?where[LocalizedPost.en.title][in]=${localizedPostTitle}`);
const data2 = await queryRes2.json();
it('should allow querying by a localized nested relationship property with many relationTos', async () => {
const relationshipBTitle = 'lawleifjawelifjew';
const relationshipB = await fetch(`${url}/api/relationship-b?depth=0`, {
body: JSON.stringify({
title: relationshipBTitle,
}),
headers,
method: 'post',
}).then((res) => res.json());
expect(queryRes2.status).toBe(200);
expect(data2.docs).toHaveLength(1);
expect(relationshipB.doc.id).toBeDefined();
const res = await fetch(`${url}/api/relationship-a`, {
body: JSON.stringify({
postManyRelationships: {
value: relationshipB.doc.id,
relationTo: 'relationship-b',
},
}),
headers,
method: 'post',
});
expect(res.status).toBe(201);
const queryRes = await fetch(`${url}/api/relationship-a?where[postManyRelationships.value][equals]=${relationshipB.doc.id}`);
const data = await queryRes.json();
expect(data.docs).toHaveLength(1);
});
it('should allow querying by a numeric custom ID', async () => {
const customID = 1988;
const customIDResult = await fetch(`${url}/api/custom-id?depth=0`, {
body: JSON.stringify({
id: customID,
name: 'woohoo',
}),
headers,
method: 'post',
}).then((res) => res.json());
expect(customIDResult.doc.id).toStrictEqual(customID);
await fetch(`${url}/api/custom-id?depth=0`, {
body: JSON.stringify({
id: 2343452,
name: 'another post',
}),
headers,
method: 'post',
}).then((res) => res.json());
const queryRes1 = await fetch(`${url}/api/custom-id?where[id][equals]=${customID}`, {
headers,
});
const data1 = await queryRes1.json();
expect(data1.docs).toHaveLength(1);
const queryByIDRes = await fetch(`${url}/api/custom-id/${customID}`, {
headers,
});
const queryByIDData = await queryByIDRes.json();
expect(queryByIDData.id).toStrictEqual(customID);
});
it('should allow querying by a field within a group', async () => {

View File

@@ -1,3 +1,5 @@
/* eslint-disable no-use-before-define */
/* eslint-disable no-nested-ternary */
import { Config, SanitizedConfig } from './types';
import sanitize from './sanitize';
@@ -8,8 +10,14 @@ import sanitize from './sanitize';
*/
export function buildConfig(config: Config): SanitizedConfig {
if (Array.isArray(config.plugins)) {
const configWithPlugins = config.plugins.reduce((updatedConfig, plugin) => plugin(updatedConfig), config);
return sanitize(configWithPlugins);
const configWithPlugins = config.plugins.reduce(
(updatedConfig, plugin) => plugin(updatedConfig),
config,
);
const sanitizedConfig = sanitize(configWithPlugins);
return sanitizedConfig;
}
return sanitize(config);

View File

@@ -19,6 +19,9 @@ export const defaults = {
scss: path.resolve(__dirname, '../admin/scss/overrides.scss'),
dateFormat: 'MMMM do yyyy, h:mm a',
},
typescript: {
outputFile: `${typeof process?.cwd === 'function' ? process.cwd() : ''}/payload-types.ts`,
},
upload: {},
graphQL: {
maxComplexity: 1000,

View File

@@ -29,6 +29,9 @@ export default joi.object({
graphQL: joi.string(),
graphQLPlayground: joi.string(),
}),
typescript: joi.object({
outputFile: joi.string(),
}),
collections: joi.array(),
globals: joi.array(),
admin: joi.object({

View File

@@ -25,7 +25,7 @@ type GeneratePreviewURLOptions = {
token: string
}
export type GeneratePreviewURL = (doc: Record<string, unknown>, options: GeneratePreviewURLOptions) => string
export type GeneratePreviewURL = (doc: Record<string, unknown>, options: GeneratePreviewURLOptions) => Promise<string> | string
export type EmailTransport = Email & {
transport: Transporter;
@@ -112,6 +112,9 @@ export type Config = {
graphQL?: string;
graphQLPlayground?: string;
};
typescript?: {
outputFile?: string
}
debug?: boolean
express?: {
json?: {

View File

@@ -66,6 +66,7 @@ const getBaseUploadFields = ({ config, collection }: Options): Field[] => {
name: 'filename',
label: 'File Name',
type: 'text',
index: true,
admin: {
readOnly: true,
disabled: true,

View File

@@ -4,6 +4,7 @@ import { Editor } from 'slate';
import { PayloadRequest } from '../../express/types';
import { Document } from '../../types';
import { ConditionalDateProps } from '../../admin/components/elements/DatePicker/types';
import { Description } from '../../admin/components/forms/FieldDescription/types';
export type FieldHook = (args: {
value?: unknown,
@@ -40,8 +41,6 @@ type Admin = {
hidden?: boolean
}
export type Description = string | ((value: Record<string, unknown>) => string);
export type Labels = {
singular: string;
plural: string;

View File

@@ -154,15 +154,6 @@ class ParamParser {
const currentSchemaTypeOptions = getSchemaTypeOptions(currentSchemaType);
if (currentSchemaTypeOptions.localized) {
const upcomingSegment = pathSegments[i + 1];
const upcomingPath = `${currentPath}.${upcomingSegment}`;
const upcomingSchemaType = schema.path(upcomingPath);
if (upcomingSchemaType) {
lastIncompletePath.path = currentPath;
return;
}
const localePath = `${currentPath}.${this.locale}`;
const localizedSchemaType = schema.path(localePath);
@@ -170,6 +161,15 @@ class ParamParser {
lastIncompletePath.path = localePath;
return;
}
const upcomingSegment = pathSegments[i + 1];
const upcomingPathWithLocale = `${currentPath}.${this.locale}.${upcomingSegment}`;
const upcomingSchemaTypeWithLocale = schema.path(upcomingPathWithLocale);
if (upcomingSchemaTypeWithLocale) {
lastIncompletePath.path = upcomingPathWithLocale;
return;
}
}
lastIncompletePath.path = currentPath;
@@ -305,12 +305,10 @@ class ParamParser {
}
if (typeof formattedValue === 'string') {
const parsedNumber = parseFloat(formattedValue);
if (!Number.isNaN(parsedNumber)) {
if (!Number.isNaN(formattedValue)) {
query.$or.push({
[path]: {
[operatorKey]: parsedNumber,
[operatorKey]: parseFloat(formattedValue),
},
});
}

View File

@@ -107,6 +107,11 @@ const buildSchema = (config: SanitizedConfig, configFields: Field[], buildSchema
}
});
if (buildSchemaOptions?.options?.timestamps) {
indexFields.push({ createdAt: 1 });
indexFields.push({ updatedAt: 1 });
}
const schema = new Schema(fields, options);
indexFields.forEach((index) => {
schema.index(index);

View File

@@ -8,7 +8,7 @@ export const sanitizeQueryValue = (schemaType: SchemaType, path: string, operato
// Disregard invalid _ids
if (path === '_id' && typeof val === 'string') {
if (path === '_id' && typeof val === 'string' && val.split(',').length === 1) {
if (schemaType?.instance === 'ObjectID') {
const isValid = mongoose.Types.ObjectId.isValid(val);
@@ -69,32 +69,29 @@ export const sanitizeQueryValue = (schemaType: SchemaType, path: string, operato
}
}
if (['all', 'not_in'].includes(operator) && typeof formattedValue === 'string') {
if (['all', 'not_in', 'in'].includes(operator) && typeof formattedValue === 'string') {
formattedValue = createArrayFromCommaDelineated(formattedValue);
}
if (schemaOptions && (schemaOptions.ref || schemaOptions.refPath)) {
if (operator === 'in') {
if (typeof formattedValue === 'string') formattedValue = createArrayFromCommaDelineated(formattedValue);
if (Array.isArray(formattedValue)) {
formattedValue = formattedValue.reduce((formattedValues, inVal) => {
const newValues = [inVal];
if (mongoose.Types.ObjectId.isValid(inVal)) newValues.push(new mongoose.Types.ObjectId(inVal));
if (schemaOptions && (schemaOptions.ref || schemaOptions.refPath) && operator === 'in') {
if (Array.isArray(formattedValue)) {
formattedValue = formattedValue.reduce((formattedValues, inVal) => {
const newValues = [inVal];
if (mongoose.Types.ObjectId.isValid(inVal)) newValues.push(new mongoose.Types.ObjectId(inVal));
const parsedNumber = parseFloat(inVal);
if (!Number.isNaN(parsedNumber)) newValues.push(parsedNumber);
const parsedNumber = parseFloat(inVal);
if (!Number.isNaN(parsedNumber)) newValues.push(parsedNumber);
return [
...formattedValues,
...newValues,
];
}, []);
}
return [
...formattedValues,
...newValues,
];
}, []);
}
}
if (operator === 'like' && path !== '_id') {
formattedValue = { $regex: formattedValue, $options: '-i' };
formattedValue = { $regex: formattedValue, $options: 'i' };
}
if (operator === 'exists') {

View File

@@ -1,3 +1,3 @@
export default function isImage(mimeType: string): boolean {
return ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'].indexOf(mimeType) > -1;
return ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml', 'image/webp'].indexOf(mimeType) > -1;
}