From e13c6fa955ecb2b238239bf437e3d5d11b3a89e5 Mon Sep 17 00:00:00 2001 From: lordie Date: Fri, 22 Jul 2022 18:05:43 -0300 Subject: [PATCH 001/130] graphql schema: check blockfield 'required' property for null-ability --- src/graphql/schema/buildObjectType.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/graphql/schema/buildObjectType.ts b/src/graphql/schema/buildObjectType.ts index afde906dff..c537c423ed 100644 --- a/src/graphql/schema/buildObjectType.ts +++ b/src/graphql/schema/buildObjectType.ts @@ -468,7 +468,7 @@ function buildObjectType(payload: Payload, name: string, fields: Field[], parent return { ...objectTypeConfig, - [field.name]: { type }, + [field.name]: { type: withNullableType(field, type) }, }; }, row: (objectTypeConfig: ObjectTypeConfig, field: RowField) => field.fields.reduce((objectTypeConfigWithRowFields, subField) => { From bc83153b60fa310b47d409919739b81c98de5656 Mon Sep 17 00:00:00 2001 From: lordie Date: Wed, 27 Jul 2022 18:17:56 -0300 Subject: [PATCH 002/130] assert every list element non null --- src/graphql/schema/buildObjectType.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/graphql/schema/buildObjectType.ts b/src/graphql/schema/buildObjectType.ts index c537c423ed..ca564f4358 100644 --- a/src/graphql/schema/buildObjectType.ts +++ b/src/graphql/schema/buildObjectType.ts @@ -9,6 +9,7 @@ import { GraphQLFloat, GraphQLInt, GraphQLList, + GraphQLNonNull, GraphQLObjectType, GraphQLString, GraphQLType, @@ -71,7 +72,7 @@ function buildObjectType(payload: Payload, name: string, fields: Field[], parent }), point: (objectTypeConfig: ObjectTypeConfig, field: PointField) => ({ ...objectTypeConfig, - [field.name]: { type: withNullableType(field, new GraphQLList(GraphQLFloat)) }, + [field.name]: { type: withNullableType(field, new GraphQLList(new GraphQLNonNull(GraphQLFloat))) }, }), richText: (objectTypeConfig: ObjectTypeConfig, field: RichTextField) => ({ ...objectTypeConfig, @@ -206,7 +207,7 @@ function buildObjectType(payload: Payload, name: string, fields: Field[], parent values: formatOptions(field), }); - type = field.hasMany ? new GraphQLList(type) : type; + type = field.hasMany ? new GraphQLList(new GraphQLNonNull(type)) : type; type = withNullableType(field, type); return { @@ -288,7 +289,7 @@ function buildObjectType(payload: Payload, name: string, fields: Field[], parent const relationship = { args: relationshipArgs, - type: hasManyValues ? new GraphQLList(type) : type, + type: hasManyValues ? new GraphQLList(new GraphQLNonNull(type)) : type, extensions: { complexity: 10 }, async resolve(parent, args, context) { const value = parent[field.name]; @@ -436,11 +437,11 @@ function buildObjectType(payload: Payload, name: string, fields: Field[], parent array: (objectTypeConfig: ObjectTypeConfig, field: ArrayField) => { const fullName = combineParentName(parentName, field.label === false ? toWords(field.name, true) : field.label); const type = buildObjectType(payload, fullName, field.fields, fullName); - const arrayType = new GraphQLList(withNullableType(field, type)); + const arrayType = new GraphQLList(new GraphQLNonNull(type)); return { ...objectTypeConfig, - [field.name]: { type: arrayType }, + [field.name]: { type: withNullableType(field, arrayType) }, }; }, group: (objectTypeConfig: ObjectTypeConfig, field: GroupField) => { @@ -460,11 +461,11 @@ function buildObjectType(payload: Payload, name: string, fields: Field[], parent const fullName = combineParentName(parentName, field.label === false ? toWords(field.name, true) : field.label); - const type = new GraphQLList(new GraphQLUnionType({ + const type = new GraphQLList(new GraphQLNonNull(new GraphQLUnionType({ name: fullName, types: blockTypes, resolveType: (data) => payload.types.blockTypes[data.blockType].name, - })); + }))); return { ...objectTypeConfig, From d0d498e9c7047c030137ecb2f895324b29520933 Mon Sep 17 00:00:00 2001 From: Falko Woudstra Date: Mon, 1 Aug 2022 23:59:26 +0200 Subject: [PATCH 003/130] Add warning PATCH update route and add deprecation warning on PUT --- src/collections/buildEndpoints.ts | 7 ++++++- src/collections/requestHandlers/update.ts | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/collections/buildEndpoints.ts b/src/collections/buildEndpoints.ts index 2d03fd146a..e62c4da90c 100644 --- a/src/collections/buildEndpoints.ts +++ b/src/collections/buildEndpoints.ts @@ -16,7 +16,7 @@ import findVersionByID from './requestHandlers/findVersionByID'; import restoreVersion from './requestHandlers/restoreVersion'; import deleteHandler from './requestHandlers/delete'; import findByID from './requestHandlers/findByID'; -import update from './requestHandlers/update'; +import update, { deprecatedUpdate } from './requestHandlers/update'; import logoutHandler from '../auth/requestHandlers/logout'; const buildEndpoints = (collection: SanitizedCollectionConfig): Endpoint[] => { @@ -122,6 +122,11 @@ const buildEndpoints = (collection: SanitizedCollectionConfig): Endpoint[] => { { path: '/:id', method: 'put', + handler: deprecatedUpdate, + }, + { + path: '/:id', + method: 'patch', handler: update, }, { diff --git a/src/collections/requestHandlers/update.ts b/src/collections/requestHandlers/update.ts index 8b2f7c2e84..e6ef173f3f 100644 --- a/src/collections/requestHandlers/update.ts +++ b/src/collections/requestHandlers/update.ts @@ -9,6 +9,12 @@ export type UpdateResult = { doc: Document }; +export async function deprecatedUpdate(req: PayloadRequest, res: Response, next: NextFunction): Promise | void> { + console.warn('The PUT method is deprecated and will no longer be supported in a future release. Please use the PATCH method for update requests.'); + + return updateHandler(req, res, next); +} + export default async function updateHandler(req: PayloadRequest, res: Response, next: NextFunction): Promise | void> { try { const draft = req.query.draft === 'true'; From f0ff1c7c99248e8e50f1da13b240902b481fa9fe Mon Sep 17 00:00:00 2001 From: Falko Woudstra Date: Tue, 2 Aug 2022 00:02:50 +0200 Subject: [PATCH 004/130] Update documentation for PATCH instead of PUT in Rest API --- docs/rest-api/overview.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rest-api/overview.mdx b/docs/rest-api/overview.mdx index fd334933f4..50101eb279 100644 --- a/docs/rest-api/overview.mdx +++ b/docs/rest-api/overview.mdx @@ -29,7 +29,7 @@ Each collection is mounted using its `slug` value. For example, if a collection' | `GET` | `/api/{collectionSlug}` | Find paginated documents | | `GET` | `/api/{collectionSlug}/:id` | Find a specific document by ID | | `POST` | `/api/{collectionSlug}` | Create a new document | -| `PUT` | `/api/{collectionSlug}/:id` | Update a document by ID | +| `PATCH` | `/api/{collectionSlug}/:id` | Update a document by ID | | `DELETE` | `/api/{collectionSlug}/:id` | Delete an existing document by ID | ##### Additional `find` query parameters From d0da3d7962bbddfbdc1c553816409823bf6e1335 Mon Sep 17 00:00:00 2001 From: KGZM Date: Thu, 11 Aug 2022 15:53:51 -0400 Subject: [PATCH 005/130] fix: saving multiple versions (#918) * test: saving multiple versions with unique fields * fix: saving multiple versions with unique fields Version schemas were getting produced with uniqueness constraints which caused updates to likely fail because each successive version would potentially reuse unchanged unique key-values (particularly ident keys like slug) from previous versions. This was due to `mongoose.buildSchema` not respecting buildSchemaOptions.disableUnique. This regression was introduced with the fix at commit c175476e. * test: eslint fix Co-authored-by: Dan Ribbens --- src/mongoose/buildSchema.ts | 2 +- test/versions/int.spec.ts | 31 ++++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index aad35351ab..c5eed1f4ff 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -80,7 +80,7 @@ const buildSchema = (config: SanitizedConfig, configFields: Field[], buildSchema if (config.indexSortableFields && !buildSchemaOptions.global && !field.index && !field.hidden && sortableFieldTypes.indexOf(field.type) > -1 && fieldAffectsData(field)) { indexFields.push({ index: { [field.name]: 1 } }); } else if (field.unique && fieldAffectsData(field)) { - indexFields.push({ index: { [field.name]: 1 }, options: { unique: true, sparse: field.localized || false } }); + indexFields.push({ index: { [field.name]: 1 }, options: { unique: !buildSchemaOptions.disableUnique, sparse: field.localized || false } }); } else if (field.index && fieldAffectsData(field)) { indexFields.push({ index: { [field.name]: 1 } }); } diff --git a/test/versions/int.spec.ts b/test/versions/int.spec.ts index 6d6dca189e..7bca5a3e95 100644 --- a/test/versions/int.spec.ts +++ b/test/versions/int.spec.ts @@ -62,7 +62,7 @@ describe('Versions', () => { collectionLocalPostID = autosavePost.id; - const updatedPost = await payload.update({ + 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, From ca852e8cb2d78982abeae0b5db4117f0261d8fed Mon Sep 17 00:00:00 2001 From: James Date: Thu, 11 Aug 2022 15:02:17 -0700 Subject: [PATCH 006/130] fix: ensures you can query on nested block fields --- src/mongoose/buildQuery.ts | 2 +- src/mongoose/connect.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/mongoose/buildQuery.ts b/src/mongoose/buildQuery.ts index 3f8d0f766e..1d80c8e829 100644 --- a/src/mongoose/buildQuery.ts +++ b/src/mongoose/buildQuery.ts @@ -200,7 +200,7 @@ class ParamParser { return; } - if (priorSchemaType.instance === 'Mixed') { + if (priorSchemaType.instance === 'Mixed' || priorSchemaType.instance === 'Array') { lastIncompletePath.path = currentPath; } } diff --git a/src/mongoose/connect.ts b/src/mongoose/connect.ts index 42f1c9a2e6..1f128b7a77 100644 --- a/src/mongoose/connect.ts +++ b/src/mongoose/connect.ts @@ -39,6 +39,8 @@ const connectMongoose = async ( try { await mongoose.connect(urlToConnect, connectionOptions); + mongoose.set('strictQuery', false); + if (process.env.PAYLOAD_DROP_DATABASE === 'true') { logger.info('---- DROPPING DATABASE ----'); await mongoose.connection.dropDatabase(); From 9237dd67da94def2c8f163ec238e241a6dd04157 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 11 Aug 2022 15:06:20 -0700 Subject: [PATCH 007/130] chore(release): v1.0.21 --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba62f8bb84..34bd8b8b74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [1.0.21](https://github.com/payloadcms/payload/compare/v1.0.20...v1.0.21) (2022-08-11) + + +### Bug Fixes + +* ensures you can query on nested block fields ([ca852e8](https://github.com/payloadcms/payload/commit/ca852e8cb2d78982abeae0b5db4117f0261d8fed)) +* saving multiple versions ([#918](https://github.com/payloadcms/payload/issues/918)) ([d0da3d7](https://github.com/payloadcms/payload/commit/d0da3d7962bbddfbdc1c553816409823bf6e1335)) + ## [1.0.20](https://github.com/payloadcms/payload/compare/v1.0.19...v1.0.20) (2022-08-11) diff --git a/package.json b/package.json index 87b7347838..b47f62eeba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "1.0.20", + "version": "1.0.21", "description": "Node, React and MongoDB Headless CMS and Application Framework", "license": "MIT", "author": { From f615abc9b1d9000aff114010ef7f618ec70b6491 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Fri, 12 Aug 2022 11:57:36 -0400 Subject: [PATCH 008/130] fix: create indexes in nested fields --- src/fields/config/types.ts | 3 +- src/mongoose/buildSchema.ts | 175 +++++++++++++---------- test/fields/collections/Indexed/index.ts | 18 +++ test/fields/int.spec.ts | 9 ++ 4 files changed, 125 insertions(+), 80 deletions(-) diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index e694d47ed6..ae1647d0c2 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -369,7 +369,8 @@ export type FieldAffectingData = | CodeField | PointField -export type NonPresentationalField = TextField +export type NonPresentationalField = + TextField | NumberField | EmailField | TextareaField diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index c5eed1f4ff..402fee44c8 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -14,7 +14,7 @@ export type BuildSchemaOptions = { global?: boolean } -type FieldSchemaGenerator = (field: Field, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions) => void; +type FieldSchemaGenerator = (field: Field, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]) => void; type Index = { index: IndexDefinition @@ -44,13 +44,11 @@ const localizeSchema = (field: NonPresentationalField, schema, localization) => return schema; }; -const buildSchema = (config: SanitizedConfig, configFields: Field[], buildSchemaOptions: BuildSchemaOptions = {}): Schema => { +const buildSchema = (config: SanitizedConfig, configFields: Field[], buildSchemaOptions: BuildSchemaOptions = {}, indexes: Index[] = []): Schema => { const { allowIDField, options } = buildSchemaOptions; let fields = {}; - let schemaFields = configFields; - const indexFields: Index[] = []; if (!allowIDField) { const idField = schemaFields.find((field) => fieldAffectsData(field) && field.name === 'id'); @@ -69,103 +67,84 @@ const buildSchema = (config: SanitizedConfig, configFields: Field[], buildSchema const addFieldSchema: FieldSchemaGenerator = fieldToSchemaMap[field.type]; if (addFieldSchema) { - addFieldSchema(field, schema, config, buildSchemaOptions); - } - - // geospatial field index must be created after the schema is created - if (fieldIndexMap[field.type]) { - indexFields.push(...fieldIndexMap[field.type](field, config)); - } - - if (config.indexSortableFields && !buildSchemaOptions.global && !field.index && !field.hidden && sortableFieldTypes.indexOf(field.type) > -1 && fieldAffectsData(field)) { - indexFields.push({ index: { [field.name]: 1 } }); - } else if (field.unique && fieldAffectsData(field)) { - indexFields.push({ index: { [field.name]: 1 }, options: { unique: !buildSchemaOptions.disableUnique, sparse: field.localized || false } }); - } else if (field.index && fieldAffectsData(field)) { - indexFields.push({ index: { [field.name]: 1 } }); + addFieldSchema(field, schema, config, buildSchemaOptions, indexes); } } }); if (buildSchemaOptions?.options?.timestamps) { - indexFields.push({ index: { createdAt: 1 } }); - indexFields.push({ index: { updatedAt: 1 } }); + indexes.push({ index: { createdAt: 1 } }); + indexes.push({ index: { updatedAt: 1 } }); } - indexFields.forEach((indexField) => { + // mongoose on mongoDB 5 or 6 need to call this to make the index in the database, schema indexes alone are not enough + indexes.forEach((indexField) => { schema.index(indexField.index, indexField.options); }); return schema; }; -const fieldIndexMap = { - point: (field: PointField, config: SanitizedConfig) => { - let direction: boolean | '2dsphere'; - const options: IndexOptions = { - unique: field.unique || false, - sparse: (field.localized && field.unique) || false, - }; - if (field.index === true || field.index === undefined) { - direction = '2dsphere'; - } - if (field.localized && config.localization) { - return config.localization.locales.map((locale) => ({ - index: { [`${field.name}.${locale}`]: direction }, - options, - })); - } - if (field.unique) { - options.unique = true; - } - return [{ index: { [field.name]: direction }, options }]; - }, +const addFieldIndex = (field: NonPresentationalField, indexFields: Index[], config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions) => { + if (config.indexSortableFields && !buildSchemaOptions.global && !field.index && !field.hidden && sortableFieldTypes.indexOf(field.type) > -1 && fieldAffectsData(field)) { + indexFields.push({ index: { [field.name]: 1 } }); + } else if (field.unique && fieldAffectsData(field)) { + indexFields.push({ index: { [field.name]: 1 }, options: { unique: !buildSchemaOptions.disableUnique, sparse: field.localized || false } }); + } else if (field.index && fieldAffectsData(field)) { + indexFields.push({ index: { [field.name]: 1 } }); + } }; -const fieldToSchemaMap = { - number: (field: NumberField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { +const fieldToSchemaMap: Record = { + number: (field: NumberField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: Number }; schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); + addFieldIndex(field, indexes, config, buildSchemaOptions); }, - text: (field: TextField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { + text: (field: TextField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: String }; schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); + addFieldIndex(field, indexes, config, buildSchemaOptions); }, - email: (field: EmailField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { + email: (field: EmailField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: String }; schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); + addFieldIndex(field, indexes, config, buildSchemaOptions); }, - textarea: (field: TextareaField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { + textarea: (field: TextareaField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: String }; schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); + addFieldIndex(field, indexes, config, buildSchemaOptions); }, - richText: (field: RichTextField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { + richText: (field: RichTextField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: Schema.Types.Mixed }; schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); + addFieldIndex(field, indexes, config, buildSchemaOptions); }, - code: (field: CodeField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { + code: (field: CodeField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: String }; schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); + addFieldIndex(field, indexes, config, buildSchemaOptions); }, - point: (field: PointField, schema: Schema, config: SanitizedConfig): void => { + point: (field: PointField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { const baseSchema = { type: { type: String, @@ -173,8 +152,8 @@ const fieldToSchemaMap = { }, coordinates: { type: [Number], - sparse: field.unique && field.localized, - unique: field.unique || false, + sparse: (buildSchemaOptions.disableUnique && field.unique) && field.localized, + unique: (buildSchemaOptions.disableUnique && field.unique) || false, required: false, default: field.defaultValue || undefined, }, @@ -183,8 +162,30 @@ const fieldToSchemaMap = { schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); + + // creates geospatial 2dsphere index by default + let direction; + const options: IndexOptions = { + unique: field.unique || false, + sparse: (field.localized && field.unique) || false, + }; + if (field.index === true || field.index === undefined) { + direction = '2dsphere'; + } + if (field.localized && config.localization) { + indexes.push( + ...config.localization.locales.map((locale) => ({ + index: { [`${field.name}.${locale}`]: direction }, + options, + })), + ); + } + if (field.unique) { + options.unique = true; + } + indexes.push({ index: { [field.name]: direction }, options }); }, - radio: (field: RadioField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { + radio: (field: RadioField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: String, @@ -197,22 +198,25 @@ const fieldToSchemaMap = { schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); + addFieldIndex(field, indexes, config, buildSchemaOptions); }, - checkbox: (field: CheckboxField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { + checkbox: (field: CheckboxField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: Boolean }; schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); + addFieldIndex(field, indexes, config, buildSchemaOptions); }, - date: (field: DateField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { + date: (field: DateField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: Date }; schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); + addFieldIndex(field, indexes, config, buildSchemaOptions); }, - upload: (field: UploadField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { + upload: (field: UploadField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: Schema.Types.Mixed, @@ -222,8 +226,9 @@ const fieldToSchemaMap = { schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); + addFieldIndex(field, indexes, config, buildSchemaOptions); }, - relationship: (field: RelationshipField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions) => { + relationship: (field: RelationshipField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]) => { const hasManyRelations = Array.isArray(field.relationTo); let schemaToReturn: { [key: string]: any } = {}; @@ -276,51 +281,57 @@ const fieldToSchemaMap = { schema.add({ [field.name]: schemaToReturn, }); + addFieldIndex(field, indexes, config, buildSchemaOptions); }, - row: (field: RowField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { + row: (field: RowField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { field.fields.forEach((subField: Field) => { const addFieldSchema: FieldSchemaGenerator = fieldToSchemaMap[subField.type]; if (addFieldSchema) { - addFieldSchema(subField, schema, config, buildSchemaOptions); + addFieldSchema(subField, schema, config, buildSchemaOptions, indexes); } }); }, - collapsible: (field: CollapsibleField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { + collapsible: (field: CollapsibleField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { field.fields.forEach((subField: Field) => { const addFieldSchema: FieldSchemaGenerator = fieldToSchemaMap[subField.type]; if (addFieldSchema) { - addFieldSchema(subField, schema, config, buildSchemaOptions); + addFieldSchema(subField, schema, config, buildSchemaOptions, indexes); } }); }, - tabs: (field: TabsField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { + tabs: (field: TabsField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { field.tabs.forEach((tab) => { tab.fields.forEach((subField: Field) => { const addFieldSchema: FieldSchemaGenerator = fieldToSchemaMap[subField.type]; if (addFieldSchema) { - addFieldSchema(subField, schema, config, buildSchemaOptions); + addFieldSchema(subField, schema, config, buildSchemaOptions, indexes); } }); }); }, - array: (field: ArrayField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions) => { + array: (field: ArrayField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]) => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), - type: [buildSchema(config, field.fields, { - options: { _id: false, id: false }, - allowIDField: true, - disableUnique: buildSchemaOptions.disableUnique, - })], + type: [buildSchema( + config, + field.fields, + { + options: { _id: false, id: false }, + allowIDField: true, + disableUnique: buildSchemaOptions.disableUnique, + }, + indexes, + )], }; schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); }, - group: (field: GroupField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { + group: (field: GroupField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { let { required } = field; if (field?.admin?.condition || field?.localized || field?.access?.create) required = false; @@ -329,20 +340,25 @@ const fieldToSchemaMap = { const baseSchema = { ...formattedBaseSchema, required: required && field.fields.some((subField) => (!fieldIsPresentationalOnly(subField) && subField.required && !subField.localized && !subField?.admin?.condition && !subField?.access?.create)), - type: buildSchema(config, field.fields, { - options: { - _id: false, - id: false, + type: buildSchema( + config, + field.fields, + { + options: { + _id: false, + id: false, + }, + disableUnique: buildSchemaOptions.disableUnique, }, - disableUnique: buildSchemaOptions.disableUnique, - }), + indexes, + ), }; schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); }, - select: (field: SelectField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { + select: (field: SelectField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: String, @@ -356,8 +372,9 @@ const fieldToSchemaMap = { schema.add({ [field.name]: field.hasMany ? [schemaToReturn] : schemaToReturn, }); + addFieldIndex(field, indexes, config, buildSchemaOptions); }, - blocks: (field: BlockField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { + blocks: (field: BlockField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { const fieldSchema = [new Schema({}, { _id: false, discriminatorKey: 'blockType' })]; let schemaToReturn; @@ -380,7 +397,7 @@ const fieldToSchemaMap = { blockItem.fields.forEach((blockField) => { const addFieldSchema: FieldSchemaGenerator = fieldToSchemaMap[blockField.type]; if (addFieldSchema) { - addFieldSchema(blockField, blockSchema, config, buildSchemaOptions); + addFieldSchema(blockField, blockSchema, config, buildSchemaOptions, indexes); } }); diff --git a/test/fields/collections/Indexed/index.ts b/test/fields/collections/Indexed/index.ts index 08d91f2678..139c28d010 100644 --- a/test/fields/collections/Indexed/index.ts +++ b/test/fields/collections/Indexed/index.ts @@ -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, + }, + ], + }, ], }; diff --git a/test/fields/int.spec.ts b/test/fields/int.spec.ts index 660b33bd4c..bf72d4a45c 100644 --- a/test/fields/int.spec.ts +++ b/test/fields/int.spec.ts @@ -118,6 +118,9 @@ describe('Fields', () => { const options: Record = {}; 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, IndexOptions]; indexes.forEach((index) => { @@ -147,6 +150,12 @@ 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', () => { From 9165b25fd645f9939bd0020c3fa06b0bb46cc8f2 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Fri, 12 Aug 2022 12:01:34 -0400 Subject: [PATCH 009/130] chore: remove unused devDependency mongodb --- package.json | 1 - src/collections/init.ts | 3 +-- yarn.lock | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 87b7347838..99e9790cf7 100644 --- a/package.json +++ b/package.json @@ -271,7 +271,6 @@ "get-port": "5.1.1", "glob": "^8.0.3", "graphql-request": "^3.4.0", - "mongodb": "^3.6.2", "mongodb-memory-server": "^7.2.0", "nodemon": "^2.0.6", "passport-strategy": "^1.0.0", diff --git a/src/collections/init.ts b/src/collections/init.ts index feb0b89b2e..fb94bdde98 100644 --- a/src/collections/init.ts +++ b/src/collections/init.ts @@ -1,9 +1,8 @@ -import mongoose, { UpdateAggregationStage } from 'mongoose'; +import mongoose, { UpdateAggregationStage, UpdateQuery } from 'mongoose'; import paginate from 'mongoose-paginate-v2'; import express from 'express'; import passport from 'passport'; import passportLocalMongoose from 'passport-local-mongoose'; -import { UpdateQuery } from 'mongodb'; import { buildVersionCollectionFields } from '../versions/buildCollectionFields'; import buildQueryPlugin from '../mongoose/buildQuery'; import apiKeyStrategy from '../auth/strategies/apiKey'; diff --git a/yarn.lock b/yarn.lock index f42ecedf39..d4d7256281 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8658,7 +8658,7 @@ mongodb@4.8.1: optionalDependencies: saslprep "^1.0.3" -mongodb@^3.6.2, mongodb@^3.7.3: +mongodb@^3.7.3: version "3.7.3" resolved "https://registry.npmjs.org/mongodb/-/mongodb-3.7.3.tgz#b7949cfd0adc4cc7d32d3f2034214d4475f175a5" integrity sha512-Psm+g3/wHXhjBEktkxXsFMZvd3nemI0r3IPsE0bU+4//PnvNWKkzhZcEsbPcYiWqe8XqXJJEg4Tgtr7Raw67Yw== From b8421ddc0c9357de7a61bdc565fe2f9c4cf62681 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 12 Aug 2022 12:18:02 -0700 Subject: [PATCH 010/130] fix: #905 --- src/admin/components/forms/field-types/Tabs/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/admin/components/forms/field-types/Tabs/index.tsx b/src/admin/components/forms/field-types/Tabs/index.tsx index 8e605e9117..80dea69f2c 100644 --- a/src/admin/components/forms/field-types/Tabs/index.tsx +++ b/src/admin/components/forms/field-types/Tabs/index.tsx @@ -68,6 +68,7 @@ const TabsField: React.FC = (props) => { description={activeTab.description} /> Date: Fri, 12 Aug 2022 13:11:49 -0700 Subject: [PATCH 011/130] chore: adds commenting to obscure form function --- src/admin/components/forms/Form/fieldReducer.ts | 1 - src/admin/components/forms/Form/index.tsx | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/admin/components/forms/Form/fieldReducer.ts b/src/admin/components/forms/Form/fieldReducer.ts index 78311a4555..cca9653630 100644 --- a/src/admin/components/forms/Form/fieldReducer.ts +++ b/src/admin/components/forms/Form/fieldReducer.ts @@ -1,5 +1,4 @@ import equal from 'deep-equal'; -import ObjectID from 'bson-objectid'; import { unflatten, flatten } from 'flatley'; import flattenFilters from './flattenFilters'; import getSiblingData from './getSiblingData'; diff --git a/src/admin/components/forms/Form/index.tsx b/src/admin/components/forms/Form/index.tsx index 46d0b67864..f87dc7175f 100644 --- a/src/admin/components/forms/Form/index.tsx +++ b/src/admin/components/forms/Form/index.tsx @@ -367,6 +367,10 @@ const Form: React.FC = (props) => { refreshCookie(); }, 15000, [fields]); + // Re-run form validation every second + // as fields change, because field validations can + // potentially rely on OTHER field values to determine + // if they are valid or not (siblingData, data) useThrottledEffect(() => { validateForm(); }, 1000, [validateForm, fields]); From b860959feac2e58806d68da642fe3b37bdb003fc Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Fri, 12 Aug 2022 16:35:08 -0400 Subject: [PATCH 012/130] fix: point index --- src/mongoose/buildSchema.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index 402fee44c8..cf6413090f 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -89,7 +89,13 @@ const addFieldIndex = (field: NonPresentationalField, indexFields: Index[], conf if (config.indexSortableFields && !buildSchemaOptions.global && !field.index && !field.hidden && sortableFieldTypes.indexOf(field.type) > -1 && fieldAffectsData(field)) { indexFields.push({ index: { [field.name]: 1 } }); } else if (field.unique && fieldAffectsData(field)) { - indexFields.push({ index: { [field.name]: 1 }, options: { unique: !buildSchemaOptions.disableUnique, sparse: field.localized || false } }); + indexFields.push({ + index: { [field.name]: 1 }, + options: { + unique: !buildSchemaOptions.disableUnique, + sparse: field.localized || false, + }, + }); } else if (field.index && fieldAffectsData(field)) { indexFields.push({ index: { [field.name]: 1 } }); } @@ -179,11 +185,12 @@ const fieldToSchemaMap: Record = { options, })), ); + } else { + if (field.unique) { + options.unique = true; + } + indexes.push({ index: { [field.name]: direction }, options }); } - if (field.unique) { - options.unique = true; - } - indexes.push({ index: { [field.name]: direction }, options }); }, radio: (field: RadioField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { const baseSchema = { From 566c6ba3a9beb13ea9437844313ec6701effce27 Mon Sep 17 00:00:00 2001 From: Felix Hofmann Date: Fri, 12 Aug 2022 12:36:47 +0200 Subject: [PATCH 013/130] feat: add new pickerAppearance option 'monthOnly' --- docs/fields/date.mdx | 4 +- .../elements/DatePicker/DatePicker.tsx | 2 + .../components/elements/DatePicker/types.ts | 12 +++- test/fields/collections/Date/index.ts | 63 +++++++++++++++++++ test/fields/config.ts | 3 + test/fields/payload-types.ts | 14 +++++ 6 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 test/fields/collections/Date/index.ts diff --git a/docs/fields/date.mdx b/docs/fields/date.mdx index 7ccf812c1b..54c1858d39 100644 --- a/docs/fields/date.mdx +++ b/docs/fields/date.mdx @@ -38,8 +38,8 @@ In addition to the default [field admin config](/docs/fields/overview#admin-conf | Option | Description | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`pickerAppearance`** | Determines the appearance of the datepicker: `dayAndTime` `timeOnly` `dayOnly`. Defaults to `dayAndTime`. | -| **`displayFormat`** | Determines how the date is presented. dayAndTime default to `MMM d, yyy h:mm a` timeOnly defaults to `h:mm a` and dayOnly defaults to `MMM d, yyy`. | +| **`pickerAppearance`** | Determines the appearance of the datepicker: `dayAndTime` `timeOnly` `dayOnly` `monthOnly`. Defaults to `dayAndTime`. | +| **`displayFormat`** | Determines how the date is presented. dayAndTime default to `MMM d, yyy h:mm a` timeOnly defaults to `h:mm a` dayOnly defaults to `MMM d, yyy` and monthOnly defaults to `MM/yyyy`. | | **`placeholder`** | Placeholder text for the field. | | **`monthsToShow`** | Number of months to display max is 2. Defaults to 1. | | **`minDate`** | Passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). | diff --git a/src/admin/components/elements/DatePicker/DatePicker.tsx b/src/admin/components/elements/DatePicker/DatePicker.tsx index d45624534c..be0f18d4ac 100644 --- a/src/admin/components/elements/DatePicker/DatePicker.tsx +++ b/src/admin/components/elements/DatePicker/DatePicker.tsx @@ -31,6 +31,7 @@ const DateTime: React.FC = (props) => { if (dateTimeFormat === undefined) { if (pickerAppearance === 'dayAndTime') dateTimeFormat = 'MMM d, yyy h:mm a'; else if (pickerAppearance === 'timeOnly') dateTimeFormat = 'h:mm a'; + else if (pickerAppearance === 'monthOnly') dateTimeFormat = 'MM/yyyy'; else dateTimeFormat = 'MMM d, yyy'; } @@ -50,6 +51,7 @@ const DateTime: React.FC = (props) => { showPopperArrow: false, selected: value && new Date(value), customInputRef: 'ref', + showMonthYearPicker: pickerAppearance === 'monthOnly', }; const classes = [ diff --git a/src/admin/components/elements/DatePicker/types.ts b/src/admin/components/elements/DatePicker/types.ts index c7ba02d0f0..13af88cf0e 100644 --- a/src/admin/components/elements/DatePicker/types.ts +++ b/src/admin/components/elements/DatePicker/types.ts @@ -1,6 +1,6 @@ type SharedProps = { - displayFormat?: string | undefined - pickerAppearance?: 'dayAndTime' | 'timeOnly' | 'dayOnly' + displayFormat?: string + pickerAppearance?: 'dayAndTime' | 'timeOnly' | 'dayOnly' | 'monthOnly' } type TimePickerProps = { @@ -16,6 +16,11 @@ type DayPickerProps = { maxDate?: Date } +type MonthPickerProps = { + minDate?: Date + maxDate?: Date +} + export type ConditionalDateProps = | SharedProps & DayPickerProps & TimePickerProps & { pickerAppearance?: 'dayAndTime' @@ -26,6 +31,9 @@ export type ConditionalDateProps = | SharedProps & DayPickerProps & { pickerAppearance: 'dayOnly' } + | SharedProps & MonthPickerProps & { + pickerAppearance: 'monthOnly' + } export type Props = SharedProps & DayPickerProps & TimePickerProps & { value?: Date diff --git a/test/fields/collections/Date/index.ts b/test/fields/collections/Date/index.ts new file mode 100644 index 0000000000..3d3882bb3e --- /dev/null +++ b/test/fields/collections/Date/index.ts @@ -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; diff --git a/test/fields/config.ts b/test/fields/config.ts index f361cef2de..cf068c3021 100644 --- a/test/fields/config.ts +++ b/test/fields/config.ts @@ -6,6 +6,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'; @@ -44,6 +45,7 @@ export default buildConfig({ NumberFields, Uploads, IndexedFields, + DateFields, ], localization: { defaultLocale: 'en', @@ -66,6 +68,7 @@ export default buildConfig({ 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 }); const createdTextDoc = await payload.create({ collection: 'text-fields', data: textDoc }); diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index 7b0f6cbf5c..34c7857078 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -295,6 +295,20 @@ export interface IndexedField { 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; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users". From fba0847f0fbc4c144ec85bb7a1ed3f2a953f5e05 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 12 Aug 2022 16:08:36 -0700 Subject: [PATCH 014/130] fix: ensures you can query on mixed schema type within blocks --- src/mongoose/buildQuery.ts | 2 ++ test/fields/collections/Blocks/index.ts | 5 +++++ test/fields/config.ts | 9 ++++++++- test/fields/int.spec.ts | 24 ++++++++++++++++++++++++ test/localization/int.spec.ts | 6 +++--- 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/mongoose/buildQuery.ts b/src/mongoose/buildQuery.ts index 1d80c8e829..e0bface9a8 100644 --- a/src/mongoose/buildQuery.ts +++ b/src/mongoose/buildQuery.ts @@ -203,6 +203,8 @@ class ParamParser { if (priorSchemaType.instance === 'Mixed' || priorSchemaType.instance === 'Array') { lastIncompletePath.path = currentPath; } + } else { + lastIncompletePath.path = currentPath; } if (operator === 'near') { diff --git a/test/fields/collections/Blocks/index.ts b/test/fields/collections/Blocks/index.ts index d8b8c091cd..f0196b2415 100644 --- a/test/fields/collections/Blocks/index.ts +++ b/test/fields/collections/Blocks/index.ts @@ -14,6 +14,10 @@ export const blocksField: Field = { type: 'text', required: true, }, + { + name: 'richText', + type: 'richText', + }, ], }, { @@ -76,6 +80,7 @@ export const blocksFieldSeedData = [ blockName: 'First block', blockType: 'text', text: 'first block', + richText: [], }, { blockName: 'Second block', diff --git a/test/fields/config.ts b/test/fields/config.ts index f361cef2de..434beef4dc 100644 --- a/test/fields/config.ts +++ b/test/fields/config.ts @@ -59,7 +59,6 @@ export default buildConfig({ }); await payload.create({ collection: 'array-fields', data: arrayDoc }); - await payload.create({ collection: 'block-fields', data: blocksDoc }); await payload.create({ collection: 'collapsible-fields', data: collapsibleDoc }); await payload.create({ collection: 'conditional-logic', data: conditionalLogicDoc }); await payload.create({ collection: 'group-fields', data: groupDoc }); @@ -89,5 +88,13 @@ export default buildConfig({ await payload.create({ collection: 'rich-text-fields', data: richTextDocWithRelationship }); await payload.create({ collection: 'number-fields', data: numberDoc }); + + const blocksDocWithRichText = { ...blocksDoc }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + blocksDocWithRichText.blocks[0].richText = richTextDocWithRelationship.richText; + + await payload.create({ collection: 'block-fields', data: blocksDocWithRichText }); }, }); diff --git a/test/fields/int.spec.ts b/test/fields/int.spec.ts index 660b33bd4c..4eadbef796 100644 --- a/test/fields/int.spec.ts +++ b/test/fields/int.spec.ts @@ -305,6 +305,30 @@ 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); + }); }); describe('richText', () => { diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index 9e97e6b90f..9ff5cfe629 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -230,7 +230,7 @@ describe('Localization', () => { const result = await payload.find({ 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, }, }, From e7caaf57a93f9e813b1381cb3eabff4275faeef2 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 12 Aug 2022 16:25:55 -0700 Subject: [PATCH 015/130] chore(release): v1.0.22 --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34bd8b8b74..655516f0ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [1.0.22](https://github.com/payloadcms/payload/compare/v1.0.21...v1.0.22) (2022-08-12) + + +### Bug Fixes + +* [#905](https://github.com/payloadcms/payload/issues/905) ([b8421dd](https://github.com/payloadcms/payload/commit/b8421ddc0c9357de7a61bdc565fe2f9c4cf62681)) +* ensures you can query on mixed schema type within blocks ([fba0847](https://github.com/payloadcms/payload/commit/fba0847f0fbc4c144ec85bb7a1ed3f2a953f5e05)) + ## [1.0.21](https://github.com/payloadcms/payload/compare/v1.0.20...v1.0.21) (2022-08-11) diff --git a/package.json b/package.json index b47f62eeba..d9bdcaebbd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "1.0.21", + "version": "1.0.22", "description": "Node, React and MongoDB Headless CMS and Application Framework", "license": "MIT", "author": { From d0744f370270f160da29fe9ae9772c8e8c8d6f53 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Sat, 13 Aug 2022 07:44:36 -0400 Subject: [PATCH 016/130] fix: nested schema indexes --- src/mongoose/buildSchema.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index cf6413090f..6f52479427 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -44,9 +44,10 @@ const localizeSchema = (field: NonPresentationalField, schema, localization) => return schema; }; -const buildSchema = (config: SanitizedConfig, configFields: Field[], buildSchemaOptions: BuildSchemaOptions = {}, indexes: Index[] = []): Schema => { +const buildSchema = (config: SanitizedConfig, configFields: Field[], buildSchemaOptions: BuildSchemaOptions = {}): Schema => { const { allowIDField, options } = buildSchemaOptions; let fields = {}; + const indexes: Index[] = []; let schemaFields = configFields; @@ -319,7 +320,7 @@ const fieldToSchemaMap: Record = { }); }); }, - array: (field: ArrayField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]) => { + array: (field: ArrayField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions) => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: [buildSchema( @@ -330,7 +331,6 @@ const fieldToSchemaMap: Record = { allowIDField: true, disableUnique: buildSchemaOptions.disableUnique, }, - indexes, )], }; @@ -338,7 +338,7 @@ const fieldToSchemaMap: Record = { [field.name]: localizeSchema(field, baseSchema, config.localization), }); }, - group: (field: GroupField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { + group: (field: GroupField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { let { required } = field; if (field?.admin?.condition || field?.localized || field?.access?.create) required = false; @@ -357,7 +357,6 @@ const fieldToSchemaMap: Record = { }, disableUnique: buildSchemaOptions.disableUnique, }, - indexes, ), }; From 4115045c155f51e6566b3877f4cfd01f6f9be850 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Sat, 13 Aug 2022 08:08:40 -0400 Subject: [PATCH 017/130] test: flaky admin test fix (#929) --- test/admin/e2e.spec.ts | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/test/admin/e2e.spec.ts b/test/admin/e2e.spec.ts index 35ec1306a2..45a525e04c 100644 --- a/test/admin/e2e.spec.ts +++ b/test/admin/e2e.spec.ts @@ -238,36 +238,28 @@ describe('admin', () => { describe('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); }); }); }); From 145e1db05db0e71149ba74e95764970dfdfd8b6b Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Sat, 13 Aug 2022 13:04:24 -0400 Subject: [PATCH 018/130] fix: dev:generate-types on all test configs (#919) --- src/bin/generateTypes.ts | 5 +-- test/access-control/payload-types.ts | 2 +- test/admin/payload-types.ts | 8 +++++ test/array-update/payload-types.ts | 2 +- test/auth/payload-types.ts | 2 +- test/buildConfig.ts | 3 -- test/fields-relationship/payload-types.ts | 26 -------------- test/globals/payload-types.ts | 41 +++++++++++++++++++++++ test/localization/payload-types.ts | 29 ++++++++-------- test/uploads/payload-types.ts | 23 ++++++++++--- test/versions/payload-types.ts | 28 ++++++++++++++-- 11 files changed, 114 insertions(+), 55 deletions(-) create mode 100644 test/globals/payload-types.ts diff --git a/src/bin/generateTypes.ts b/src/bin/generateTypes.ts index b1a6fa3234..8016c16799 100644 --- a/src/bin/generateTypes.ts +++ b/src/bin/generateTypes.ts @@ -390,6 +390,7 @@ function configToJsonSchema(config: SanitizedConfig): JSONSchema4 { export function generateTypes(): void { const logger = Logger(); const config = loadConfig(); + const outputFile = process.env.PAYLOAD_TS_OUTPUT_PATH || config.typescript.outputFile; logger.info('Compiling TS types for Collections and Globals...'); @@ -402,8 +403,8 @@ export function generateTypes(): void { singleQuote: true, }, }).then((compiled) => { - fs.writeFileSync(config.typescript.outputFile, compiled); - logger.info(`Types written to ${config.typescript.outputFile}`); + fs.writeFileSync(outputFile, compiled); + logger.info(`Types written to ${outputFile}`); }); } diff --git a/test/access-control/payload-types.ts b/test/access-control/payload-types.ts index 867e05f970..1a6091d0a2 100644 --- a/test/access-control/payload-types.ts +++ b/test/access-control/payload-types.ts @@ -52,7 +52,7 @@ export interface RestrictedVersion { */ export interface SiblingDatum { id: string; - array?: { + array: { allowPublicReadability?: boolean; text?: string; id?: string; diff --git a/test/admin/payload-types.ts b/test/admin/payload-types.ts index 8416d61e02..b1e2c80c45 100644 --- a/test/admin/payload-types.ts +++ b/test/admin/payload-types.ts @@ -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". diff --git a/test/array-update/payload-types.ts b/test/array-update/payload-types.ts index 5dfead267f..4e850b84c3 100644 --- a/test/array-update/payload-types.ts +++ b/test/array-update/payload-types.ts @@ -12,7 +12,7 @@ export interface Config {} */ export interface Array { id: string; - array?: { + array: { required: string; optional?: string; id?: string; diff --git a/test/auth/payload-types.ts b/test/auth/payload-types.ts index fe69f84827..197be73123 100644 --- a/test/auth/payload-types.ts +++ b/test/auth/payload-types.ts @@ -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; } diff --git a/test/buildConfig.ts b/test/buildConfig.ts index c4ea2888a0..3b5c9b76c4 100644 --- a/test/buildConfig.ts +++ b/test/buildConfig.ts @@ -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, }; diff --git a/test/fields-relationship/payload-types.ts b/test/fields-relationship/payload-types.ts index 5185244292..e668538a58 100644 --- a/test/fields-relationship/payload-types.ts +++ b/test/fields-relationship/payload-types.ts @@ -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". diff --git a/test/globals/payload-types.ts b/test/globals/payload-types.ts new file mode 100644 index 0000000000..a73ed769e2 --- /dev/null +++ b/test/globals/payload-types.ts @@ -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; +} diff --git a/test/localization/payload-types.ts b/test/localization/payload-types.ts index 8717f40832..6bfc1edbe9 100644 --- a/test/localization/payload-types.ts +++ b/test/localization/payload-types.ts @@ -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; -} diff --git a/test/uploads/payload-types.ts b/test/uploads/payload-types.ts index 7a846af56d..8c8ceb6ed4 100644 --- a/test/uploads/payload-types.ts +++ b/test/uploads/payload-types.ts @@ -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". diff --git a/test/versions/payload-types.ts b/test/versions/payload-types.ts index 545c3ef3f1..07ed78d49d 100644 --- a/test/versions/payload-types.ts +++ b/test/versions/payload-types.ts @@ -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; } From cbb1c84be76146301ce41c4bdace647df83a4aac Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Aug 2022 10:40:52 -0700 Subject: [PATCH 019/130] fix: #930 --- package.json | 2 +- src/admin/components/views/collections/Edit/index.tsx | 11 +++++++++-- yarn.lock | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index d9bdcaebbd..106c74f0b3 100644 --- a/package.json +++ b/package.json @@ -163,7 +163,7 @@ "react-dom": "^18.0.0", "react-helmet": "^6.1.0", "react-router-dom": "^5.1.2", - "react-router-navigation-prompt": "^1.8.11", + "react-router-navigation-prompt": "^1.9.6", "react-select": "^3.0.8", "react-simple-code-editor": "^0.11.0", "react-toastify": "^8.2.0", diff --git a/src/admin/components/views/collections/Edit/index.tsx b/src/admin/components/views/collections/Edit/index.tsx index 2bd498bd35..81614a7e97 100644 --- a/src/admin/components/views/collections/Edit/index.tsx +++ b/src/admin/components/views/collections/Edit/index.tsx @@ -36,6 +36,7 @@ const EditView: React.FC = (props) => { const [fields] = useState(() => formatFields(incomingCollection, isEditing)); const [collection] = useState(() => ({ ...incomingCollection, fields })); + const [redirect, setRedirect] = useState(); const locale = useLocale(); const { serverURL, routes: { admin, api } } = useConfig(); @@ -51,12 +52,12 @@ const EditView: React.FC = (props) => { const onSave = useCallback(async (json: any) => { getVersions(); if (!isEditing) { - history.push(`${admin}/collections/${collection.slug}/${json?.doc?.id}`); + setRedirect(`${admin}/collections/${collection.slug}/${json?.doc?.id}`); } else { const state = await buildStateFromSchema({ fieldSchema: collection.fields, data: json.doc, user, id, operation: 'update', locale }); setInitialState(state); } - }, [admin, collection, history, isEditing, getVersions, user, id, locale]); + }, [admin, collection, isEditing, getVersions, user, id, locale]); const [{ data, isLoading: isLoadingDocument, isError }] = usePayloadAPI( (isEditing ? `${serverURL}${api}/${slug}/${id}` : null), @@ -111,6 +112,12 @@ const EditView: React.FC = (props) => { awaitInitialState(); }, [dataToRender, fields, isEditing, id, user, locale, isLoadingDocument, preferencesKey, getPreference]); + useEffect(() => { + if (redirect) { + history.push(redirect); + } + }, [history, redirect]); + if (isError) { return ( diff --git a/yarn.lock b/yarn.lock index f42ecedf39..3ff7e5c3a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10531,7 +10531,7 @@ react-router-dom@^5.1.2: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" -react-router-navigation-prompt@^1.8.11: +react-router-navigation-prompt@^1.9.6: version "1.9.6" resolved "https://registry.npmjs.org/react-router-navigation-prompt/-/react-router-navigation-prompt-1.9.6.tgz#a949252dfbae8c40508671beb6d5995f0b089ac4" integrity sha512-l0sAtbroHK8i1/Eyy29XcrMpBEt0R08BaScgMUt8r5vWWbLz7G0ChOikayTCQm7QgDFsHw8gVnxDJb7TBZCAKg== From 0a40dd43cb0e50a58488d33f0c3446c895b4972b Mon Sep 17 00:00:00 2001 From: James Mikrut Date: Sun, 14 Aug 2022 11:04:05 -0700 Subject: [PATCH 020/130] test: adds dataloader test (#936) * chore: adds dataloader test config * test: passing dataloader test --- test/dataloader/config.ts | 47 ++++++++++++++++++++++++++++++++ test/dataloader/int.spec.ts | 53 +++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 test/dataloader/config.ts create mode 100644 test/dataloader/int.spec.ts diff --git a/test/dataloader/config.ts b/test/dataloader/config.ts new file mode 100644 index 0000000000..551a84b5a0 --- /dev/null +++ b/test/dataloader/config.ts @@ -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', +}; diff --git a/test/dataloader/int.spec.ts b/test/dataloader/int.spec.ts new file mode 100644 index 0000000000..32459eb88d --- /dev/null +++ b/test/dataloader/int.spec.ts @@ -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); + }); + }); +}); From 78630cafa20043370f40eaf7a7c72239a58aafd0 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Aug 2022 18:54:13 -0700 Subject: [PATCH 021/130] chore(release): v1.0.23 --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 655516f0ba..3e3495eb14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [1.0.23](https://github.com/payloadcms/payload/compare/v1.0.22...v1.0.23) (2022-08-15) + + +### Bug Fixes + +* [#930](https://github.com/payloadcms/payload/issues/930) ([cbb1c84](https://github.com/payloadcms/payload/commit/cbb1c84be76146301ce41c4bdace647df83a4aac)) +* dev:generate-types on all test configs ([#919](https://github.com/payloadcms/payload/issues/919)) ([145e1db](https://github.com/payloadcms/payload/commit/145e1db05db0e71149ba74e95764970dfdfd8b6b)) + ## [1.0.22](https://github.com/payloadcms/payload/compare/v1.0.21...v1.0.22) (2022-08-12) diff --git a/package.json b/package.json index 106c74f0b3..ba108794a7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "1.0.22", + "version": "1.0.23", "description": "Node, React and MongoDB Headless CMS and Application Framework", "license": "MIT", "author": { From 1c7445dc7fd883f6d5dcba532e9e048b1cff08f5 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Mon, 15 Aug 2022 11:42:29 -0400 Subject: [PATCH 022/130] fix: format graphql localization input type (#932) * fix: format locales for graphql * fix: locale missing from graphql mutation args --- src/collections/graphql/init.ts | 6 +++ src/globals/graphql/init.ts | 3 ++ .../schema/buildFallbackLocaleInputType.ts | 3 +- src/graphql/schema/buildLocaleInputType.ts | 23 +++++---- test/localization/int.spec.ts | 49 +++++++++++++++++++ 5 files changed, 73 insertions(+), 11 deletions(-) diff --git a/src/collections/graphql/init.ts b/src/collections/graphql/init.ts index b000926ccb..ddbc3b0a36 100644 --- a/src/collections/graphql/init.ts +++ b/src/collections/graphql/init.ts @@ -177,6 +177,9 @@ function initCollectionsGraphQL(payload: Payload): void { args: { data: { type: collection.graphQL.mutationInputType }, draft: { type: GraphQLBoolean }, + ...(payload.config.localization ? { + locale: { type: payload.types.localeInputType }, + } : {}), }, resolve: createResolver(collection), }; @@ -188,6 +191,9 @@ function initCollectionsGraphQL(payload: Payload): void { data: { type: collection.graphQL.updateMutationInputType }, draft: { type: GraphQLBoolean }, autosave: { type: GraphQLBoolean }, + ...(payload.config.localization ? { + locale: { type: payload.types.localeInputType }, + } : {}), }, resolve: updateResolver(collection), }; diff --git a/src/globals/graphql/init.ts b/src/globals/graphql/init.ts index a997fa65e4..89da370e63 100644 --- a/src/globals/graphql/init.ts +++ b/src/globals/graphql/init.ts @@ -62,6 +62,9 @@ function initGlobalsGraphQL(payload: Payload): void { args: { data: { type: global.graphQL.mutationInputType }, draft: { type: GraphQLBoolean }, + ...(payload.config.localization ? { + locale: { type: payload.types.localeInputType }, + } : {}), }, resolve: updateResolver(global), }; diff --git a/src/graphql/schema/buildFallbackLocaleInputType.ts b/src/graphql/schema/buildFallbackLocaleInputType.ts index 4dbfeeee9a..71e58a0005 100644 --- a/src/graphql/schema/buildFallbackLocaleInputType.ts +++ b/src/graphql/schema/buildFallbackLocaleInputType.ts @@ -1,11 +1,12 @@ import { GraphQLEnumType } from 'graphql'; import { LocalizationConfig } from '../../config/types'; +import formatName from '../utilities/formatName'; const buildFallbackLocaleInputType = (localization: LocalizationConfig): GraphQLEnumType => new GraphQLEnumType({ name: 'FallbackLocaleInputType', values: [...localization.locales, 'none'].reduce((values, locale) => ({ ...values, - [locale]: { + [formatName(locale)]: { value: locale, }, }), {}), diff --git a/src/graphql/schema/buildLocaleInputType.ts b/src/graphql/schema/buildLocaleInputType.ts index b115fdc917..8ed0843535 100644 --- a/src/graphql/schema/buildLocaleInputType.ts +++ b/src/graphql/schema/buildLocaleInputType.ts @@ -1,14 +1,17 @@ -import { GraphQLEnumType } from 'graphql'; +import { GraphQLEnumType, GraphQLScalarType } from 'graphql'; import { LocalizationConfig } from '../../config/types'; +import formatName from '../utilities/formatName'; -const buildLocaleInputType = (localization: LocalizationConfig): GraphQLEnumType => new GraphQLEnumType({ - name: 'LocaleInputType', - values: localization.locales.reduce((values, locale) => ({ - ...values, - [locale]: { - value: locale, - }, - }), {}), -}); +const buildLocaleInputType = (localization: LocalizationConfig): GraphQLEnumType | GraphQLScalarType => { + return new GraphQLEnumType({ + name: 'LocaleInputType', + values: localization.locales.reduce((values, locale) => ({ + ...values, + [formatName(locale)]: { + value: locale, + }, + }), {}), + }); +}; export default buildLocaleInputType; diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index 9ff5cfe629..b3c02f6534 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -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); + }); }); }); From 48f929f3ab18def38df926d29dd3298f49525bdb Mon Sep 17 00:00:00 2001 From: James Date: Mon, 15 Aug 2022 13:03:13 -0700 Subject: [PATCH 023/130] chore: begins to simplify index creation --- src/mongoose/buildSchema.ts | 119 ++++++++---------------------------- 1 file changed, 27 insertions(+), 92 deletions(-) diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index 6f52479427..709fa31812 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -2,10 +2,9 @@ /* eslint-disable class-methods-use-this */ /* eslint-disable @typescript-eslint/no-use-before-define */ /* eslint-disable no-use-before-define */ -import { IndexDefinition, IndexOptions, Schema, SchemaOptions } from 'mongoose'; +import { IndexOptions, Schema, SchemaOptions } from 'mongoose'; import { SanitizedConfig } from '../config/types'; import { ArrayField, Block, BlockField, CheckboxField, CodeField, CollapsibleField, DateField, EmailField, Field, fieldAffectsData, fieldIsPresentationalOnly, GroupField, NonPresentationalField, NumberField, PointField, RadioField, RelationshipField, RichTextField, RowField, SelectField, TabsField, TextareaField, TextField, UploadField } from '../fields/config/types'; -import sortableFieldTypes from '../fields/sortableFieldTypes'; export type BuildSchemaOptions = { options?: SchemaOptions @@ -14,12 +13,7 @@ export type BuildSchemaOptions = { global?: boolean } -type FieldSchemaGenerator = (field: Field, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]) => void; - -type Index = { - index: IndexDefinition - options?: IndexOptions -} +type FieldSchemaGenerator = (field: Field, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions) => void; const formatBaseSchema = (field: NonPresentationalField, buildSchemaOptions: BuildSchemaOptions) => ({ sparse: field.unique && field.localized, @@ -38,7 +32,6 @@ const localizeSchema = (field: NonPresentationalField, schema, localization) => _id: false, }), localized: true, - index: schema.index, }; } return schema; @@ -47,7 +40,6 @@ const localizeSchema = (field: NonPresentationalField, schema, localization) => const buildSchema = (config: SanitizedConfig, configFields: Field[], buildSchemaOptions: BuildSchemaOptions = {}): Schema => { const { allowIDField, options } = buildSchemaOptions; let fields = {}; - const indexes: Index[] = []; let schemaFields = configFields; @@ -68,90 +60,58 @@ const buildSchema = (config: SanitizedConfig, configFields: Field[], buildSchema const addFieldSchema: FieldSchemaGenerator = fieldToSchemaMap[field.type]; if (addFieldSchema) { - addFieldSchema(field, schema, config, buildSchemaOptions, indexes); + addFieldSchema(field, schema, config, buildSchemaOptions); } } }); - if (buildSchemaOptions?.options?.timestamps) { - indexes.push({ index: { createdAt: 1 } }); - indexes.push({ index: { updatedAt: 1 } }); - } - - // mongoose on mongoDB 5 or 6 need to call this to make the index in the database, schema indexes alone are not enough - indexes.forEach((indexField) => { - schema.index(indexField.index, indexField.options); - }); - return schema; }; -const addFieldIndex = (field: NonPresentationalField, indexFields: Index[], config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions) => { - if (config.indexSortableFields && !buildSchemaOptions.global && !field.index && !field.hidden && sortableFieldTypes.indexOf(field.type) > -1 && fieldAffectsData(field)) { - indexFields.push({ index: { [field.name]: 1 } }); - } else if (field.unique && fieldAffectsData(field)) { - indexFields.push({ - index: { [field.name]: 1 }, - options: { - unique: !buildSchemaOptions.disableUnique, - sparse: field.localized || false, - }, - }); - } else if (field.index && fieldAffectsData(field)) { - indexFields.push({ index: { [field.name]: 1 } }); - } -}; - const fieldToSchemaMap: Record = { - number: (field: NumberField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { + number: (field: NumberField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: Number }; schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); - addFieldIndex(field, indexes, config, buildSchemaOptions); }, - text: (field: TextField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { + text: (field: TextField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: String }; schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); - addFieldIndex(field, indexes, config, buildSchemaOptions); }, - email: (field: EmailField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { + email: (field: EmailField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: String }; schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); - addFieldIndex(field, indexes, config, buildSchemaOptions); }, - textarea: (field: TextareaField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { + textarea: (field: TextareaField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: String }; schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); - addFieldIndex(field, indexes, config, buildSchemaOptions); }, - richText: (field: RichTextField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { + richText: (field: RichTextField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: Schema.Types.Mixed }; schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); - addFieldIndex(field, indexes, config, buildSchemaOptions); }, - code: (field: CodeField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { + code: (field: CodeField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: String }; schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); - addFieldIndex(field, indexes, config, buildSchemaOptions); }, - point: (field: PointField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { + point: (field: PointField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const baseSchema = { type: { type: String, @@ -170,30 +130,11 @@ const fieldToSchemaMap: Record = { [field.name]: localizeSchema(field, baseSchema, config.localization), }); - // creates geospatial 2dsphere index by default - let direction; - const options: IndexOptions = { - unique: field.unique || false, - sparse: (field.localized && field.unique) || false, - }; - if (field.index === true || field.index === undefined) { - direction = '2dsphere'; - } - if (field.localized && config.localization) { - indexes.push( - ...config.localization.locales.map((locale) => ({ - index: { [`${field.name}.${locale}`]: direction }, - options, - })), - ); - } else { - if (field.unique) { - options.unique = true; - } - indexes.push({ index: { [field.name]: direction }, options }); - } + // if (field.index === true || field.index === undefined) { + // baseSchema.index = '2dsphere'; + // } }, - radio: (field: RadioField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { + radio: (field: RadioField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: String, @@ -206,25 +147,22 @@ const fieldToSchemaMap: Record = { schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); - addFieldIndex(field, indexes, config, buildSchemaOptions); }, - checkbox: (field: CheckboxField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { + checkbox: (field: CheckboxField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: Boolean }; schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); - addFieldIndex(field, indexes, config, buildSchemaOptions); }, - date: (field: DateField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { + date: (field: DateField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: Date }; schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); - addFieldIndex(field, indexes, config, buildSchemaOptions); }, - upload: (field: UploadField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { + upload: (field: UploadField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: Schema.Types.Mixed, @@ -234,9 +172,8 @@ const fieldToSchemaMap: Record = { schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); - addFieldIndex(field, indexes, config, buildSchemaOptions); }, - relationship: (field: RelationshipField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]) => { + relationship: (field: RelationshipField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions) => { const hasManyRelations = Array.isArray(field.relationTo); let schemaToReturn: { [key: string]: any } = {}; @@ -289,33 +226,32 @@ const fieldToSchemaMap: Record = { schema.add({ [field.name]: schemaToReturn, }); - addFieldIndex(field, indexes, config, buildSchemaOptions); }, - row: (field: RowField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { + row: (field: RowField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { field.fields.forEach((subField: Field) => { const addFieldSchema: FieldSchemaGenerator = fieldToSchemaMap[subField.type]; if (addFieldSchema) { - addFieldSchema(subField, schema, config, buildSchemaOptions, indexes); + addFieldSchema(subField, schema, config, buildSchemaOptions); } }); }, - collapsible: (field: CollapsibleField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { + collapsible: (field: CollapsibleField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { field.fields.forEach((subField: Field) => { const addFieldSchema: FieldSchemaGenerator = fieldToSchemaMap[subField.type]; if (addFieldSchema) { - addFieldSchema(subField, schema, config, buildSchemaOptions, indexes); + addFieldSchema(subField, schema, config, buildSchemaOptions); } }); }, - tabs: (field: TabsField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { + tabs: (field: TabsField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { field.tabs.forEach((tab) => { tab.fields.forEach((subField: Field) => { const addFieldSchema: FieldSchemaGenerator = fieldToSchemaMap[subField.type]; if (addFieldSchema) { - addFieldSchema(subField, schema, config, buildSchemaOptions, indexes); + addFieldSchema(subField, schema, config, buildSchemaOptions); } }); }); @@ -364,7 +300,7 @@ const fieldToSchemaMap: Record = { [field.name]: localizeSchema(field, baseSchema, config.localization), }); }, - select: (field: SelectField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { + select: (field: SelectField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: String, @@ -378,9 +314,8 @@ const fieldToSchemaMap: Record = { schema.add({ [field.name]: field.hasMany ? [schemaToReturn] : schemaToReturn, }); - addFieldIndex(field, indexes, config, buildSchemaOptions); }, - blocks: (field: BlockField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions, indexes: Index[]): void => { + blocks: (field: BlockField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const fieldSchema = [new Schema({}, { _id: false, discriminatorKey: 'blockType' })]; let schemaToReturn; @@ -403,7 +338,7 @@ const fieldToSchemaMap: Record = { blockItem.fields.forEach((blockField) => { const addFieldSchema: FieldSchemaGenerator = fieldToSchemaMap[blockField.type]; if (addFieldSchema) { - addFieldSchema(blockField, blockSchema, config, buildSchemaOptions, indexes); + addFieldSchema(blockField, blockSchema, config, buildSchemaOptions); } }); From 2ca526bd22acc7e72483a95132b438395cd574ee Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Mon, 15 Aug 2022 16:54:23 -0400 Subject: [PATCH 024/130] chore: simplifies index creation --- src/mongoose/buildSchema.ts | 43 ++++++++++++++++++-------- test/fields/collections/Point/index.ts | 3 +- test/fields/int.spec.ts | 30 ++++++++++++++++-- 3 files changed, 59 insertions(+), 17 deletions(-) diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index 709fa31812..dd66529371 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -2,7 +2,7 @@ /* eslint-disable class-methods-use-this */ /* eslint-disable @typescript-eslint/no-use-before-define */ /* eslint-disable no-use-before-define */ -import { IndexOptions, Schema, SchemaOptions } from 'mongoose'; +import { IndexOptions, Schema, SchemaOptions, SchemaTypeOptions } from 'mongoose'; import { SanitizedConfig } from '../config/types'; import { ArrayField, Block, BlockField, CheckboxField, CodeField, CollapsibleField, DateField, EmailField, Field, fieldAffectsData, fieldIsPresentationalOnly, GroupField, NonPresentationalField, NumberField, PointField, RadioField, RelationshipField, RichTextField, RowField, SelectField, TabsField, TextareaField, TextField, UploadField } from '../fields/config/types'; @@ -15,12 +15,17 @@ export type BuildSchemaOptions = { type FieldSchemaGenerator = (field: Field, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions) => void; -const formatBaseSchema = (field: NonPresentationalField, buildSchemaOptions: BuildSchemaOptions) => ({ - sparse: field.unique && field.localized, - unique: (!buildSchemaOptions.disableUnique && field.unique) || false, - required: false, - index: field.index || field.unique || false, -}); +const formatBaseSchema = (field: NonPresentationalField, buildSchemaOptions: BuildSchemaOptions) => { + const schema: SchemaTypeOptions = { + unique: (!buildSchemaOptions.disableUnique && field.unique) || false, + required: false, + index: field.index || field.unique || false, + }; + if (field.unique && field.localized) { + schema.sparse = true; + } + return schema; +}; const localizeSchema = (field: NonPresentationalField, schema, localization) => { if (field.localized && localization && Array.isArray(localization.locales)) { @@ -112,27 +117,39 @@ const fieldToSchemaMap: Record = { }); }, point: (field: PointField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { - const baseSchema = { + const baseSchema: SchemaTypeOptions = { type: { type: String, enum: ['Point'], }, coordinates: { type: [Number], - sparse: (buildSchemaOptions.disableUnique && field.unique) && field.localized, - unique: (buildSchemaOptions.disableUnique && field.unique) || false, required: false, default: field.defaultValue || undefined, }, }; + if (buildSchemaOptions.disableUnique && field.unique && field.localized) { + baseSchema.coordinates.sparse = true; + } schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); - // if (field.index === true || field.index === undefined) { - // baseSchema.index = '2dsphere'; - // } + if (field.index === true || field.index === undefined) { + const indexOptions: IndexOptions = {}; + if (!buildSchemaOptions.disableUnique && field.unique) { + indexOptions.sparse = true; + indexOptions.unique = true; + } + if (field.localized && config.localization) { + config.localization.locales.forEach((locale) => { + schema.index({ [`${field.name}.${locale}`]: '2dsphere' }, indexOptions); + }); + } else { + schema.index({ [field.name]: '2dsphere' }, indexOptions); + } + } }, radio: (field: RadioField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const baseSchema = { diff --git a/test/fields/collections/Point/index.ts b/test/fields/collections/Point/index.ts index 9032dc90aa..ef051e20ea 100644 --- a/test/fields/collections/Point/index.ts +++ b/test/fields/collections/Point/index.ts @@ -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] }, }; diff --git a/test/fields/int.spec.ts b/test/fields/int.spec.ts index e99e111f8b..a36d8b8260 100644 --- a/test/fields/int.spec.ts +++ b/test/fields/int.spec.ts @@ -160,6 +160,9 @@ describe('Fields', () => { describe('point', () => { let doc; + const point = [7, -7]; + const localized = [5, -2]; + const group = { point: [1, 9] }; beforeAll(async () => { const findDoc = await payload.find({ @@ -183,9 +186,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: { @@ -199,6 +199,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; From ff4c1a1c01b24e04c0852e645c0aef983e2c3d0a Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Mon, 15 Aug 2022 20:56:39 -0400 Subject: [PATCH 025/130] chore: simplifies indexing * fix: create indexes in nested fields * chore: remove unused devDependency mongodb * fix: point index * fix: nested schema indexes * chore: begins to simplify index creation * chore: simplifies index creation Co-authored-by: James --- package.json | 1 - src/collections/init.ts | 3 +- src/fields/config/types.ts | 3 +- src/mongoose/buildSchema.ts | 129 +++++++++-------------- test/fields/collections/Indexed/index.ts | 18 ++++ test/fields/collections/Point/index.ts | 3 +- test/fields/int.spec.ts | 39 ++++++- yarn.lock | 2 +- 8 files changed, 112 insertions(+), 86 deletions(-) diff --git a/package.json b/package.json index ba108794a7..6e8b616f24 100644 --- a/package.json +++ b/package.json @@ -271,7 +271,6 @@ "get-port": "5.1.1", "glob": "^8.0.3", "graphql-request": "^3.4.0", - "mongodb": "^3.6.2", "mongodb-memory-server": "^7.2.0", "nodemon": "^2.0.6", "passport-strategy": "^1.0.0", diff --git a/src/collections/init.ts b/src/collections/init.ts index feb0b89b2e..fb94bdde98 100644 --- a/src/collections/init.ts +++ b/src/collections/init.ts @@ -1,9 +1,8 @@ -import mongoose, { UpdateAggregationStage } from 'mongoose'; +import mongoose, { UpdateAggregationStage, UpdateQuery } from 'mongoose'; import paginate from 'mongoose-paginate-v2'; import express from 'express'; import passport from 'passport'; import passportLocalMongoose from 'passport-local-mongoose'; -import { UpdateQuery } from 'mongodb'; import { buildVersionCollectionFields } from '../versions/buildCollectionFields'; import buildQueryPlugin from '../mongoose/buildQuery'; import apiKeyStrategy from '../auth/strategies/apiKey'; diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index e694d47ed6..ae1647d0c2 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -369,7 +369,8 @@ export type FieldAffectingData = | CodeField | PointField -export type NonPresentationalField = TextField +export type NonPresentationalField = + TextField | NumberField | EmailField | TextareaField diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index c5eed1f4ff..dd66529371 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -2,10 +2,9 @@ /* eslint-disable class-methods-use-this */ /* eslint-disable @typescript-eslint/no-use-before-define */ /* eslint-disable no-use-before-define */ -import { IndexDefinition, IndexOptions, Schema, SchemaOptions } from 'mongoose'; +import { IndexOptions, Schema, SchemaOptions, SchemaTypeOptions } from 'mongoose'; import { SanitizedConfig } from '../config/types'; import { ArrayField, Block, BlockField, CheckboxField, CodeField, CollapsibleField, DateField, EmailField, Field, fieldAffectsData, fieldIsPresentationalOnly, GroupField, NonPresentationalField, NumberField, PointField, RadioField, RelationshipField, RichTextField, RowField, SelectField, TabsField, TextareaField, TextField, UploadField } from '../fields/config/types'; -import sortableFieldTypes from '../fields/sortableFieldTypes'; export type BuildSchemaOptions = { options?: SchemaOptions @@ -16,17 +15,17 @@ export type BuildSchemaOptions = { type FieldSchemaGenerator = (field: Field, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions) => void; -type Index = { - index: IndexDefinition - options?: IndexOptions -} - -const formatBaseSchema = (field: NonPresentationalField, buildSchemaOptions: BuildSchemaOptions) => ({ - sparse: field.unique && field.localized, - unique: (!buildSchemaOptions.disableUnique && field.unique) || false, - required: false, - index: field.index || field.unique || false, -}); +const formatBaseSchema = (field: NonPresentationalField, buildSchemaOptions: BuildSchemaOptions) => { + const schema: SchemaTypeOptions = { + unique: (!buildSchemaOptions.disableUnique && field.unique) || false, + required: false, + index: field.index || field.unique || false, + }; + if (field.unique && field.localized) { + schema.sparse = true; + } + return schema; +}; const localizeSchema = (field: NonPresentationalField, schema, localization) => { if (field.localized && localization && Array.isArray(localization.locales)) { @@ -38,7 +37,6 @@ const localizeSchema = (field: NonPresentationalField, schema, localization) => _id: false, }), localized: true, - index: schema.index, }; } return schema; @@ -48,9 +46,7 @@ const buildSchema = (config: SanitizedConfig, configFields: Field[], buildSchema const { allowIDField, options } = buildSchemaOptions; let fields = {}; - let schemaFields = configFields; - const indexFields: Index[] = []; if (!allowIDField) { const idField = schemaFields.find((field) => fieldAffectsData(field) && field.name === 'id'); @@ -71,58 +67,13 @@ const buildSchema = (config: SanitizedConfig, configFields: Field[], buildSchema if (addFieldSchema) { addFieldSchema(field, schema, config, buildSchemaOptions); } - - // geospatial field index must be created after the schema is created - if (fieldIndexMap[field.type]) { - indexFields.push(...fieldIndexMap[field.type](field, config)); - } - - if (config.indexSortableFields && !buildSchemaOptions.global && !field.index && !field.hidden && sortableFieldTypes.indexOf(field.type) > -1 && fieldAffectsData(field)) { - indexFields.push({ index: { [field.name]: 1 } }); - } else if (field.unique && fieldAffectsData(field)) { - indexFields.push({ index: { [field.name]: 1 }, options: { unique: !buildSchemaOptions.disableUnique, sparse: field.localized || false } }); - } else if (field.index && fieldAffectsData(field)) { - indexFields.push({ index: { [field.name]: 1 } }); - } } }); - if (buildSchemaOptions?.options?.timestamps) { - indexFields.push({ index: { createdAt: 1 } }); - indexFields.push({ index: { updatedAt: 1 } }); - } - - indexFields.forEach((indexField) => { - schema.index(indexField.index, indexField.options); - }); - return schema; }; -const fieldIndexMap = { - point: (field: PointField, config: SanitizedConfig) => { - let direction: boolean | '2dsphere'; - const options: IndexOptions = { - unique: field.unique || false, - sparse: (field.localized && field.unique) || false, - }; - if (field.index === true || field.index === undefined) { - direction = '2dsphere'; - } - if (field.localized && config.localization) { - return config.localization.locales.map((locale) => ({ - index: { [`${field.name}.${locale}`]: direction }, - options, - })); - } - if (field.unique) { - options.unique = true; - } - return [{ index: { [field.name]: direction }, options }]; - }, -}; - -const fieldToSchemaMap = { +const fieldToSchemaMap: Record = { number: (field: NumberField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: Number }; @@ -165,24 +116,40 @@ const fieldToSchemaMap = { [field.name]: localizeSchema(field, baseSchema, config.localization), }); }, - point: (field: PointField, schema: Schema, config: SanitizedConfig): void => { - const baseSchema = { + point: (field: PointField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { + const baseSchema: SchemaTypeOptions = { type: { type: String, enum: ['Point'], }, coordinates: { type: [Number], - sparse: field.unique && field.localized, - unique: field.unique || false, required: false, default: field.defaultValue || undefined, }, }; + if (buildSchemaOptions.disableUnique && field.unique && field.localized) { + baseSchema.coordinates.sparse = true; + } schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); + + if (field.index === true || field.index === undefined) { + const indexOptions: IndexOptions = {}; + if (!buildSchemaOptions.disableUnique && field.unique) { + indexOptions.sparse = true; + indexOptions.unique = true; + } + if (field.localized && config.localization) { + config.localization.locales.forEach((locale) => { + schema.index({ [`${field.name}.${locale}`]: '2dsphere' }, indexOptions); + }); + } else { + schema.index({ [field.name]: '2dsphere' }, indexOptions); + } + } }, radio: (field: RadioField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const baseSchema = { @@ -309,11 +276,15 @@ const fieldToSchemaMap = { array: (field: ArrayField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions) => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), - type: [buildSchema(config, field.fields, { - options: { _id: false, id: false }, - allowIDField: true, - disableUnique: buildSchemaOptions.disableUnique, - })], + type: [buildSchema( + config, + field.fields, + { + options: { _id: false, id: false }, + allowIDField: true, + disableUnique: buildSchemaOptions.disableUnique, + }, + )], }; schema.add({ @@ -329,13 +300,17 @@ const fieldToSchemaMap = { const baseSchema = { ...formattedBaseSchema, required: required && field.fields.some((subField) => (!fieldIsPresentationalOnly(subField) && subField.required && !subField.localized && !subField?.admin?.condition && !subField?.access?.create)), - type: buildSchema(config, field.fields, { - options: { - _id: false, - id: false, + type: buildSchema( + config, + field.fields, + { + options: { + _id: false, + id: false, + }, + disableUnique: buildSchemaOptions.disableUnique, }, - disableUnique: buildSchemaOptions.disableUnique, - }), + ), }; schema.add({ diff --git a/test/fields/collections/Indexed/index.ts b/test/fields/collections/Indexed/index.ts index 08d91f2678..139c28d010 100644 --- a/test/fields/collections/Indexed/index.ts +++ b/test/fields/collections/Indexed/index.ts @@ -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, + }, + ], + }, ], }; diff --git a/test/fields/collections/Point/index.ts b/test/fields/collections/Point/index.ts index 9032dc90aa..ef051e20ea 100644 --- a/test/fields/collections/Point/index.ts +++ b/test/fields/collections/Point/index.ts @@ -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] }, }; diff --git a/test/fields/int.spec.ts b/test/fields/int.spec.ts index 4eadbef796..a36d8b8260 100644 --- a/test/fields/int.spec.ts +++ b/test/fields/int.spec.ts @@ -118,6 +118,9 @@ describe('Fields', () => { const options: Record = {}; 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, IndexOptions]; indexes.forEach((index) => { @@ -147,10 +150,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({ @@ -174,9 +186,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: { @@ -190,6 +199,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; diff --git a/yarn.lock b/yarn.lock index 3ff7e5c3a9..97179bd62e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8658,7 +8658,7 @@ mongodb@4.8.1: optionalDependencies: saslprep "^1.0.3" -mongodb@^3.6.2, mongodb@^3.7.3: +mongodb@^3.7.3: version "3.7.3" resolved "https://registry.npmjs.org/mongodb/-/mongodb-3.7.3.tgz#b7949cfd0adc4cc7d32d3f2034214d4475f175a5" integrity sha512-Psm+g3/wHXhjBEktkxXsFMZvd3nemI0r3IPsE0bU+4//PnvNWKkzhZcEsbPcYiWqe8XqXJJEg4Tgtr7Raw67Yw== From b1a1575122f602ff6ba77973ab2a67893d352487 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 15 Aug 2022 17:51:53 -0700 Subject: [PATCH 026/130] fix: #939 --- src/init.ts | 3 +++ src/mongoose/connect.ts | 2 -- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/init.ts b/src/init.ts index 4cd883c59d..97b2b58001 100644 --- a/src/init.ts +++ b/src/init.ts @@ -1,6 +1,7 @@ /* eslint-disable no-param-reassign */ import express, { NextFunction, Response } from 'express'; import crypto from 'crypto'; +import mongoose from 'mongoose'; import { InitOptions, @@ -124,6 +125,7 @@ export const initAsync = async (payload: Payload, options: InitOptions): Promise payload.mongoURL = options.mongoURL; if (payload.mongoURL) { + mongoose.set('strictQuery', false); payload.mongoMemoryServer = await connectMongoose(payload.mongoURL, options.mongoOptions, payload.logger); } @@ -138,6 +140,7 @@ export const initSync = (payload: Payload, options: InitOptions): void => { payload.mongoURL = options.mongoURL; if (payload.mongoURL) { + mongoose.set('strictQuery', false); connectMongoose(payload.mongoURL, options.mongoOptions, payload.logger); } diff --git a/src/mongoose/connect.ts b/src/mongoose/connect.ts index 1f128b7a77..42f1c9a2e6 100644 --- a/src/mongoose/connect.ts +++ b/src/mongoose/connect.ts @@ -39,8 +39,6 @@ const connectMongoose = async ( try { await mongoose.connect(urlToConnect, connectionOptions); - mongoose.set('strictQuery', false); - if (process.env.PAYLOAD_DROP_DATABASE === 'true') { logger.info('---- DROPPING DATABASE ----'); await mongoose.connection.dropDatabase(); From 078e8dcc51197133788294bac6fa380b192defbc Mon Sep 17 00:00:00 2001 From: James Date: Mon, 15 Aug 2022 17:52:08 -0700 Subject: [PATCH 027/130] feat: ensures you can query on blocks via specifying locale or not specifying locale --- src/mongoose/buildQuery.ts | 37 +++++++++---------- src/mongoose/buildSchema.ts | 12 +------ test/fields/collections/Blocks/index.ts | 10 +++++- test/fields/collections/Text/index.ts | 6 ++++ test/fields/config.ts | 4 ++- test/fields/int.spec.ts | 48 +++++++++++++++++++++++++ 6 files changed, 84 insertions(+), 33 deletions(-) diff --git a/src/mongoose/buildQuery.ts b/src/mongoose/buildQuery.ts index e0bface9a8..b1e90bed44 100644 --- a/src/mongoose/buildQuery.ts +++ b/src/mongoose/buildQuery.ts @@ -142,7 +142,7 @@ class ParamParser { }, ]; - pathSegments.forEach((segment, i) => { + pathSegments.every((segment, i) => { const lastIncompletePath = paths.find(({ complete }) => !complete); const { path } = lastIncompletePath; @@ -152,7 +152,7 @@ class ParamParser { if (currentSchemaPathType === 'nested') { lastIncompletePath.path = currentPath; - return; + return true; } const upcomingSegment = pathSegments[i + 1]; @@ -161,25 +161,25 @@ class ParamParser { const currentSchemaTypeOptions = getSchemaTypeOptions(currentSchemaType); if (currentSchemaTypeOptions.localized) { + const upcomingLocalizedPath = `${currentPath}.${upcomingSegment}`; + const upcomingSchemaTypeWithLocale = schema.path(upcomingLocalizedPath); + + if (upcomingSchemaTypeWithLocale) { + lastIncompletePath.path = currentPath; + return true; + } + const localePath = `${currentPath}.${this.locale}`; const localizedSchemaType = schema.path(localePath); if (localizedSchemaType || operator === 'near') { lastIncompletePath.path = localePath; - return; - } - - const upcomingPathWithLocale = `${currentPath}.${this.locale}.${upcomingSegment}`; - const upcomingSchemaTypeWithLocale = schema.path(upcomingPathWithLocale); - - if (upcomingSchemaTypeWithLocale) { - lastIncompletePath.path = upcomingPathWithLocale; - return; + return true; } } lastIncompletePath.path = currentPath; - return; + return true; } const priorSchemaType = schema.path(path); @@ -197,19 +197,16 @@ class ParamParser { ...paths, ...this.getLocalizedPaths(RefModel, remainingPath, operator), ]; - return; - } - if (priorSchemaType.instance === 'Mixed' || priorSchemaType.instance === 'Array') { - lastIncompletePath.path = currentPath; + return false; } - } else { + } + + if (operator === 'near' || currentSchemaPathType === 'adhocOrUndefined') { lastIncompletePath.path = currentPath; } - if (operator === 'near') { - lastIncompletePath.path = currentPath; - } + return true; }); return paths; diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index dd66529371..e0421fed40 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -334,19 +334,9 @@ const fieldToSchemaMap: Record = { }, blocks: (field: BlockField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const fieldSchema = [new Schema({}, { _id: false, discriminatorKey: 'blockType' })]; - let schemaToReturn; - - if (field.localized && config.localization) { - schemaToReturn = config.localization.locales.reduce((localeSchema, locale) => ({ - ...localeSchema, - [locale]: fieldSchema, - }), {}); - } else { - schemaToReturn = fieldSchema; - } schema.add({ - [field.name]: schemaToReturn, + [field.name]: localizeSchema(field, fieldSchema, config.localization), }); field.blocks.forEach((blockItem: Block) => { diff --git a/test/fields/collections/Blocks/index.ts b/test/fields/collections/Blocks/index.ts index f0196b2415..77c0fd3905 100644 --- a/test/fields/collections/Blocks/index.ts +++ b/test/fields/collections/Blocks/index.ts @@ -72,7 +72,14 @@ export const blocksField: Field = { const BlockFields: CollectionConfig = { slug: 'block-fields', - fields: [blocksField], + fields: [ + blocksField, + { + ...blocksField, + name: 'localizedBlocks', + localized: true, + }, + ], }; export const blocksFieldSeedData = [ @@ -107,6 +114,7 @@ export const blocksFieldSeedData = [ export const blocksDoc = { blocks: blocksFieldSeedData, + localizedBlocks: blocksFieldSeedData, }; export default BlockFields; diff --git a/test/fields/collections/Text/index.ts b/test/fields/collections/Text/index.ts index 9d932239e9..58c662d286 100644 --- a/test/fields/collections/Text/index.ts +++ b/test/fields/collections/Text/index.ts @@ -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; diff --git a/test/fields/config.ts b/test/fields/config.ts index 434beef4dc..78bf1281a8 100644 --- a/test/fields/config.ts +++ b/test/fields/config.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ import path from 'path'; import fs from 'fs'; import { buildConfig } from '../buildConfig'; @@ -91,9 +92,10 @@ export default buildConfig({ const blocksDocWithRichText = { ...blocksDoc }; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore blocksDocWithRichText.blocks[0].richText = richTextDocWithRelationship.richText; + // @ts-ignore + blocksDocWithRichText.localizedBlocks[0].richText = richTextDocWithRelationship.richText; await payload.create({ collection: 'block-fields', data: blocksDocWithRichText }); }, diff --git a/test/fields/int.spec.ts b/test/fields/int.spec.ts index a36d8b8260..8f4e3bea53 100644 --- a/test/fields/int.spec.ts +++ b/test/fields/int.spec.ts @@ -362,6 +362,54 @@ describe('Fields', () => { 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', () => { From f8365abf1bb7fe6c4a21538d3e40b900f7631a55 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 15 Aug 2022 18:03:57 -0700 Subject: [PATCH 028/130] chore(release): v1.0.24 --- CHANGELOG.md | 16 ++++++++++++++++ package.json | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e3495eb14..b4ee74da2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## [1.0.24](https://github.com/payloadcms/payload/compare/v1.0.23...v1.0.24) (2022-08-16) + + +### Bug Fixes + +* [#939](https://github.com/payloadcms/payload/issues/939) ([b1a1575](https://github.com/payloadcms/payload/commit/b1a1575122f602ff6ba77973ab2a67893d352487)) +* create indexes in nested fields ([f615abc](https://github.com/payloadcms/payload/commit/f615abc9b1d9000aff114010ef7f618ec70b6491)) +* format graphql localization input type ([#932](https://github.com/payloadcms/payload/issues/932)) ([1c7445d](https://github.com/payloadcms/payload/commit/1c7445dc7fd883f6d5dcba532e9e048b1cff08f5)) +* nested schema indexes ([d0744f3](https://github.com/payloadcms/payload/commit/d0744f370270f160da29fe9ae9772c8e8c8d6f53)) +* point index ([b860959](https://github.com/payloadcms/payload/commit/b860959feac2e58806d68da642fe3b37bdb003fc)) + + +### Features + +* ensures you can query on blocks via specifying locale or not specifying locale ([078e8dc](https://github.com/payloadcms/payload/commit/078e8dcc51197133788294bac6fa380b192defbc)) + ## [1.0.23](https://github.com/payloadcms/payload/compare/v1.0.22...v1.0.23) (2022-08-15) diff --git a/package.json b/package.json index 6e8b616f24..43a64c0126 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "1.0.23", + "version": "1.0.24", "description": "Node, React and MongoDB Headless CMS and Application Framework", "license": "MIT", "author": { From 8ab4ec8d54cc73859d4c032b907e957f6d4f9931 Mon Sep 17 00:00:00 2001 From: James Mikrut Date: Mon, 15 Aug 2022 18:04:32 -0700 Subject: [PATCH 029/130] Update CHANGELOG.md --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4ee74da2e..bc17686a8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,6 @@ * [#939](https://github.com/payloadcms/payload/issues/939) ([b1a1575](https://github.com/payloadcms/payload/commit/b1a1575122f602ff6ba77973ab2a67893d352487)) * create indexes in nested fields ([f615abc](https://github.com/payloadcms/payload/commit/f615abc9b1d9000aff114010ef7f618ec70b6491)) * format graphql localization input type ([#932](https://github.com/payloadcms/payload/issues/932)) ([1c7445d](https://github.com/payloadcms/payload/commit/1c7445dc7fd883f6d5dcba532e9e048b1cff08f5)) -* nested schema indexes ([d0744f3](https://github.com/payloadcms/payload/commit/d0744f370270f160da29fe9ae9772c8e8c8d6f53)) -* point index ([b860959](https://github.com/payloadcms/payload/commit/b860959feac2e58806d68da642fe3b37bdb003fc)) ### Features From a3edbf4fef5efd8293cb4d6139b2513441cb741e Mon Sep 17 00:00:00 2001 From: James Date: Mon, 15 Aug 2022 18:41:34 -0700 Subject: [PATCH 030/130] fix: #568 --- .../forms/field-types/RichText/plugins/withHTML.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/admin/components/forms/field-types/RichText/plugins/withHTML.tsx b/src/admin/components/forms/field-types/RichText/plugins/withHTML.tsx index 371a4590cc..0edc8befab 100644 --- a/src/admin/components/forms/field-types/RichText/plugins/withHTML.tsx +++ b/src/admin/components/forms/field-types/RichText/plugins/withHTML.tsx @@ -10,7 +10,6 @@ const ELEMENT_TAGS = { H4: () => ({ type: 'h4' }), H5: () => ({ type: 'h5' }), H6: () => ({ type: 'h6' }), - IMG: (el) => ({ type: 'image', url: el.getAttribute('src') }), LI: () => ({ type: 'li' }), OL: () => ({ type: 'ol' }), P: () => ({ type: 'p' }), @@ -47,10 +46,15 @@ const deserialize = (el) => { ) { [parent] = el.childNodes; } - const children = Array.from(parent.childNodes) + + let children = Array.from(parent.childNodes) .map(deserialize) .flat(); + if (children.length === 0) { + children = [{ text: '' }]; + } + if (el.nodeName === 'BODY') { return jsx('fragment', {}, children); } From ccada2e8c99574c16fbd5aa1f8e6a16c13417953 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 15 Aug 2022 19:19:44 -0700 Subject: [PATCH 031/130] chore: migrates to PATCH for collection updates --- src/admin/api.ts | 14 ++++++++++++++ src/admin/components/elements/Autosave/index.tsx | 2 +- src/admin/components/elements/SaveDraft/index.tsx | 2 +- src/admin/components/elements/Status/index.tsx | 2 +- src/admin/components/forms/Form/types.ts | 2 +- src/admin/components/views/Account/Default.tsx | 2 +- .../components/views/collections/Edit/Default.tsx | 2 +- src/collections/requestHandlers/update.ts | 2 +- src/express/middleware/corsHeaders.ts | 2 +- test/helpers/rest.ts | 2 +- 10 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/admin/api.ts b/src/admin/api.ts index a55f205398..1750d78f0d 100644 --- a/src/admin/api.ts +++ b/src/admin/api.ts @@ -34,6 +34,20 @@ export const requests = { return fetch(url, formattedOptions); }, + patch: (url: string, options: RequestInit = { headers: {} }): Promise => { + const headers = options && options.headers ? { ...options.headers } : {}; + + const formattedOptions = { + ...options, + method: 'PATCH', + headers: { + ...headers, + }, + }; + + return fetch(url, formattedOptions); + }, + delete: (url: string, options: RequestInit = { headers: {} }): Promise => { const headers = options && options.headers ? { ...options.headers } : {}; return fetch(url, { diff --git a/src/admin/components/elements/Autosave/index.tsx b/src/admin/components/elements/Autosave/index.tsx index 47414ed5f8..2c31a54a88 100644 --- a/src/admin/components/elements/Autosave/index.tsx +++ b/src/admin/components/elements/Autosave/index.tsx @@ -77,7 +77,7 @@ const Autosave: React.FC = ({ collection, global, id, publishedDocUpdated if (collection && id) { url = `${serverURL}${api}/${collection.slug}/${id}?draft=true&autosave=true&locale=${locale}`; - method = 'PUT'; + method = 'PATCH'; } if (global) { diff --git a/src/admin/components/elements/SaveDraft/index.tsx b/src/admin/components/elements/SaveDraft/index.tsx index c74fd7f37e..e81571284b 100644 --- a/src/admin/components/elements/SaveDraft/index.tsx +++ b/src/admin/components/elements/SaveDraft/index.tsx @@ -25,7 +25,7 @@ const SaveDraft: React.FC = () => { if (collection) { action = `${serverURL}${api}/${collection.slug}${id ? `/${id}` : ''}${search}`; - if (id) method = 'PUT'; + if (id) method = 'PATCH'; } if (global) { diff --git a/src/admin/components/elements/Status/index.tsx b/src/admin/components/elements/Status/index.tsx index 66e5d2f77c..1ea0ad18ff 100644 --- a/src/admin/components/elements/Status/index.tsx +++ b/src/admin/components/elements/Status/index.tsx @@ -55,7 +55,7 @@ const Status: React.FC = () => { if (collection) { url = `${serverURL}${api}/${collection.slug}/${id}?depth=0&locale=${locale}&fallback-locale=null`; - method = 'put'; + method = 'PATCH'; } if (global) { url = `${serverURL}${api}/globals/${global.slug}?depth=0&locale=${locale}&fallback-locale=null`; diff --git a/src/admin/components/forms/Form/types.ts b/src/admin/components/forms/Form/types.ts index 2908cc286a..72ca711d1b 100644 --- a/src/admin/components/forms/Form/types.ts +++ b/src/admin/components/forms/Form/types.ts @@ -27,7 +27,7 @@ export type Preferences = { export type Props = { disabled?: boolean onSubmit?: (fields: Fields, data: Data) => void - method?: 'get' | 'put' | 'delete' | 'post' + method?: 'get' | 'patch' | 'delete' | 'post' action?: string handleResponse?: (res: Response) => void onSuccess?: (json: unknown) => void diff --git a/src/admin/components/views/Account/Default.tsx b/src/admin/components/views/Account/Default.tsx index 5cc667473c..393788bc15 100644 --- a/src/admin/components/views/Account/Default.tsx +++ b/src/admin/components/views/Account/Default.tsx @@ -61,7 +61,7 @@ const DefaultAccount: React.FC = (props) => {
= (props) => { | void> { - console.warn('The PUT method is deprecated and will no longer be supported in a future release. Please use the PATCH method for update requests.'); + req.payload.logger.warn('The PUT method is deprecated and will no longer be supported in a future release. Please use the PATCH method for update requests.'); return updateHandler(req, res, next); } diff --git a/src/express/middleware/corsHeaders.ts b/src/express/middleware/corsHeaders.ts index 8337e5a225..5e68eadd01 100644 --- a/src/express/middleware/corsHeaders.ts +++ b/src/express/middleware/corsHeaders.ts @@ -4,7 +4,7 @@ import { SanitizedConfig } from '../../config/types'; export default (config: SanitizedConfig) => ( (req: Request, res: Response, next: NextFunction) => { if (config.cors) { - res.header('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS'); + res.header('Access-Control-Allow-Methods', 'PUT, PATCH, POST, GET, DELETE, OPTIONS'); res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Content-Encoding'); if (config.cors === '*') { diff --git a/test/helpers/rest.ts b/test/helpers/rest.ts index b5c7f6aee8..3188a4b27c 100644 --- a/test/helpers/rest.ts +++ b/test/helpers/rest.ts @@ -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(); From 5825d7e64f234e8c644dc7962dbf1ca7a4a8ee64 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Tue, 16 Aug 2022 12:04:25 -0400 Subject: [PATCH 032/130] test: skip flaky sorting test for now (#945) --- test/admin/e2e.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/admin/e2e.spec.ts b/test/admin/e2e.spec.ts index 45a525e04c..0258f4a712 100644 --- a/test/admin/e2e.spec.ts +++ b/test/admin/e2e.spec.ts @@ -236,7 +236,8 @@ describe('admin', () => { }); }); - describe('sorting', () => { + // TODO: Troubleshoot flaky suite + describe.skip('sorting', () => { beforeAll(async () => { await createPost(); await createPost(); From 88fe7970074d4b162eb8fd3e9a47c54bc9e44504 Mon Sep 17 00:00:00 2001 From: Felix Hofmann Date: Tue, 16 Aug 2022 23:21:38 +0200 Subject: [PATCH 033/130] chore: remove unreachable return statement --- src/fields/getDefaultValue.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/fields/getDefaultValue.ts b/src/fields/getDefaultValue.ts index fcfc992ced..db69b4e66c 100644 --- a/src/fields/getDefaultValue.ts +++ b/src/fields/getDefaultValue.ts @@ -15,9 +15,8 @@ const getValueWithDefault = async ({ value, defaultValue, locale, user }: Args): if (defaultValue && typeof defaultValue === 'function') { return defaultValue({ locale, user }); } - return defaultValue; - return undefined; + return defaultValue; }; export default getValueWithDefault; From 7c2acb4324732b0fdd8799272ab75a67fa9e3c99 Mon Sep 17 00:00:00 2001 From: Felix Hofmann Date: Tue, 16 Aug 2022 23:22:00 +0200 Subject: [PATCH 034/130] chore: use type-var instead of 'as'-keyword for jwtDecode --- src/admin/components/utilities/Auth/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/admin/components/utilities/Auth/index.tsx b/src/admin/components/utilities/Auth/index.tsx index 6ddc76bb4b..f008d1e48c 100644 --- a/src/admin/components/utilities/Auth/index.tsx +++ b/src/admin/components/utilities/Auth/index.tsx @@ -64,7 +64,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }, [setUser, push, exp, admin, api, serverURL, userSlug]); const setToken = useCallback((token: string) => { - const decoded = jwtDecode(token) as User; + const decoded = jwtDecode(token); setUser(decoded); setTokenInMemory(token); }, []); From 040833ead825e71d1cbb7f3b06434803c923be29 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Wed, 17 Aug 2022 12:57:07 -0400 Subject: [PATCH 035/130] test: move endpoints tests to new suite --- src/config/types.ts | 1 + test/collections-rest/Endpoints/index.ts | 52 ------------ test/collections-rest/config.ts | 2 - test/collections-rest/payload-types.ts | 10 --- test/dataloader/payload-types.ts | 33 ++++++++ test/endpoints/config.ts | 80 +++++++++++++++++++ .../int.spec.ts} | 26 ++++-- test/endpoints/payload-types.ts | 39 +++++++++ test/fields/payload-types.ts | 45 +++++++++++ test/globals/config.ts | 9 --- test/globals/int.spec.ts | 12 +-- 11 files changed, 217 insertions(+), 92 deletions(-) delete mode 100644 test/collections-rest/Endpoints/index.ts create mode 100644 test/dataloader/payload-types.ts create mode 100644 test/endpoints/config.ts rename test/{collections-rest/endpoints-int.spec.ts => endpoints/int.spec.ts} (52%) create mode 100644 test/endpoints/payload-types.ts diff --git a/src/config/types.ts b/src/config/types.ts index 7844ef013a..6a319ef0c1 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -139,6 +139,7 @@ export type Config = { webpack?: (config: Configuration) => Configuration; }; collections?: CollectionConfig[]; + endpoints?: Endpoint[]; globals?: GlobalConfig[]; serverURL?: string; cookiePrefix?: string; diff --git a/test/collections-rest/Endpoints/index.ts b/test/collections-rest/Endpoints/index.ts deleted file mode 100644 index 4221883c11..0000000000 --- a/test/collections-rest/Endpoints/index.ts +++ /dev/null @@ -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; diff --git a/test/collections-rest/config.ts b/test/collections-rest/config.ts index 9e114549a6..e471b4499e 100644 --- a/test/collections-rest/config.ts +++ b/test/collections-rest/config.ts @@ -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; @@ -121,7 +120,6 @@ export default buildConfig({ }, ], }, - Endpoints, ], onInit: async (payload) => { await payload.create({ diff --git a/test/collections-rest/payload-types.ts b/test/collections-rest/payload-types.ts index c12c10847a..58116fe6d3 100644 --- a/test/collections-rest/payload-types.ts +++ b/test/collections-rest/payload-types.ts @@ -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". diff --git a/test/dataloader/payload-types.ts b/test/dataloader/payload-types.ts new file mode 100644 index 0000000000..6a77cff1be --- /dev/null +++ b/test/dataloader/payload-types.ts @@ -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; +} diff --git a/test/endpoints/config.ts b/test/endpoints/config.ts new file mode 100644 index 0000000000..ea94d8b413 --- /dev/null +++ b/test/endpoints/config.ts @@ -0,0 +1,80 @@ +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 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: [], + }, + ], + onInit: async (payload) => { + await payload.create({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + }, + }); + }, +}); diff --git a/test/collections-rest/endpoints-int.spec.ts b/test/endpoints/int.spec.ts similarity index 52% rename from test/collections-rest/endpoints-int.spec.ts rename to test/endpoints/int.spec.ts index 05ae6a7c92..bf52ba0e9a 100644 --- a/test/collections-rest/endpoints-int.spec.ts +++ b/test/endpoints/int.spec.ts @@ -1,38 +1,48 @@ import { initPayloadTest } from '../helpers/configHelpers'; -import { endpointsSlug } from './Endpoints'; import { RESTClient } from '../helpers/rest'; -import { slug } from '../globals/config'; +import { collectionSlug, globalEndpoint, globalSlug } from './config'; require('isomorphic-fetch'); let client: RESTClient; -describe('Collections - Endpoints', () => { +describe('Endpoints', () => { beforeAll(async () => { const config = await initPayloadTest({ __dirname, init: { local: false } }); const { serverURL } = config; - client = new RESTClient(config, { serverURL, defaultSlug: slug }); + client = new RESTClient(config, { serverURL, defaultSlug: collectionSlug }); }); - describe('Endpoints', () => { + + describe('Collections', () => { it('should GET a static endpoint', async () => { - const { status, data } = await client.endpoint(`/${endpointsSlug}/say-hello/joe-bloggs`); + 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(`/${endpointsSlug}/say-hello/${name}`); + 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(`/${endpointsSlug}/whoami`, 'post', params); + 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); + }); + }); }); diff --git a/test/endpoints/payload-types.ts b/test/endpoints/payload-types.ts new file mode 100644 index 0000000000..fee2200bda --- /dev/null +++ b/test/endpoints/payload-types.ts @@ -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; +} diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index 34c7857078..be91c47f53 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -40,6 +40,45 @@ export interface BlockField { blocks: ( | { 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'; + } + )[]; + localizedBlocks: ( + | { + text: string; + richText?: { + [k: string]: unknown; + }[]; id?: string; blockName?: string; blockType: 'text'; @@ -188,6 +227,9 @@ export interface TabsField { blocks: ( | { text: string; + richText?: { + [k: string]: unknown; + }[]; id?: string; blockName?: string; blockType: 'text'; @@ -235,6 +277,7 @@ export interface TabsField { export interface TextField { id: string; text: string; + localizedText?: string; defaultFunction?: string; defaultAsync?: string; createdAt: string; @@ -292,6 +335,8 @@ export interface IndexedField { */ point?: [number, number]; }; + collapsibleLocalizedUnique?: string; + collapsibleTextUnique?: string; createdAt: string; updatedAt: string; } diff --git a/test/globals/config.ts b/test/globals/config.ts index 031db91751..e4cd5841ab 100644 --- a/test/globals/config.ts +++ b/test/globals/config.ts @@ -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, diff --git a/test/globals/int.spec.ts b/test/globals/int.spec.ts index 847678c628..2a43ffefee 100644 --- a/test/globals/int.spec.ts +++ b/test/globals/int.spec.ts @@ -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', () => { From 771bbaedbcc69d368864008f554d6dc5bcd839d4 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Wed, 17 Aug 2022 13:06:58 -0400 Subject: [PATCH 036/130] chore: validation reuses endpoints schema --- src/collections/config/schema.ts | 10 ++-------- src/config/schema.ts | 10 ++++++++++ src/globals/config/schema.ts | 10 ++-------- test/endpoints/config.ts | 10 ++++++++++ test/endpoints/int.spec.ts | 12 +++++++++++- 5 files changed, 35 insertions(+), 17 deletions(-) diff --git a/src/collections/config/schema.ts b/src/collections/config/schema.ts index 5439a86293..dd5adcb3d3 100644 --- a/src/collections/config/schema.ts +++ b/src/collections/config/schema.ts @@ -1,5 +1,6 @@ import joi from 'joi'; import { componentSchema } from '../../utilities/componentSchema'; +import { endpointsSchema } from '../../config/schema'; const strategyBaseSchema = joi.object().keys({ refresh: joi.boolean(), @@ -61,14 +62,7 @@ const collectionSchema = joi.object().keys({ afterRefresh: joi.array().items(joi.func()), afterForgotPassword: joi.array().items(joi.func()), }), - endpoints: joi.array().items(joi.object({ - path: joi.string(), - method: joi.string().valid('get', 'head', 'post', 'put', 'patch', 'delete', 'connect', 'options'), - handler: joi.alternatives().try( - joi.array().items(joi.func()), - joi.func(), - ), - })), + endpoints: endpointsSchema, auth: joi.alternatives().try( joi.object({ tokenExpiration: joi.number(), diff --git a/src/config/schema.ts b/src/config/schema.ts index 76d3a6a5c9..5bd744b4d5 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -5,6 +5,15 @@ const component = joi.alternatives().try( joi.func(), ); +export const endpointsSchema = joi.array().items(joi.object({ + path: joi.string(), + method: joi.string().valid('get', 'head', 'post', 'put', 'patch', 'delete', 'connect', 'options'), + handler: joi.alternatives().try( + joi.array().items(joi.func()), + joi.func(), + ), +})); + export default joi.object({ serverURL: joi.string() .uri() @@ -33,6 +42,7 @@ export default joi.object({ outputFile: joi.string(), }), collections: joi.array(), + endpoints: endpointsSchema, globals: joi.array(), admin: joi.object({ user: joi.string(), diff --git a/src/globals/config/schema.ts b/src/globals/config/schema.ts index 77f3a75394..72987857e0 100644 --- a/src/globals/config/schema.ts +++ b/src/globals/config/schema.ts @@ -1,5 +1,6 @@ import joi from 'joi'; import { componentSchema } from '../../utilities/componentSchema'; +import { endpointsSchema } from '../../config/schema'; const globalSchema = joi.object().keys({ slug: joi.string().required(), @@ -18,14 +19,7 @@ const globalSchema = joi.object().keys({ beforeRead: joi.array().items(joi.func()), afterRead: joi.array().items(joi.func()), }), - endpoints: joi.array().items(joi.object({ - path: joi.string(), - method: joi.string().valid('get', 'head', 'post', 'put', 'patch', 'delete', 'connect', 'options'), - handler: joi.alternatives().try( - joi.array().items(joi.func()), - joi.func(), - ), - })), + endpoints: endpointsSchema, access: joi.object({ read: joi.func(), readVersions: joi.func(), diff --git a/test/endpoints/config.ts b/test/endpoints/config.ts index ea94d8b413..af2631af57 100644 --- a/test/endpoints/config.ts +++ b/test/endpoints/config.ts @@ -8,6 +8,7 @@ export const collectionSlug = 'endpoints'; export const globalSlug = 'global-endpoints'; export const globalEndpoint = 'global'; +export const applicationEndpoint = 'path'; export default buildConfig({ collections: [ @@ -68,6 +69,15 @@ export default buildConfig({ fields: [], }, ], + endpoints: [ + { + path: applicationEndpoint, + method: 'post', + handler: (req: PayloadRequest, res: Response): void => { + res.json(req.body); + }, + }, + ], onInit: async (payload) => { await payload.create({ collection: 'users', diff --git a/test/endpoints/int.spec.ts b/test/endpoints/int.spec.ts index bf52ba0e9a..5cb6f0c55f 100644 --- a/test/endpoints/int.spec.ts +++ b/test/endpoints/int.spec.ts @@ -1,6 +1,6 @@ import { initPayloadTest } from '../helpers/configHelpers'; import { RESTClient } from '../helpers/rest'; -import { collectionSlug, globalEndpoint, globalSlug } from './config'; +import { applicationEndpoint, collectionSlug, globalEndpoint, globalSlug } from './config'; require('isomorphic-fetch'); @@ -45,4 +45,14 @@ describe('Endpoints', () => { expect(params).toMatchObject(data); }); }); + + describe('Application', () => { + 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); + }); + }); }); From 11d8fc71e8bdb62c6755789903702b0ee257b448 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Wed, 17 Aug 2022 13:28:50 -0400 Subject: [PATCH 037/130] feat: custom api endpoints --- src/config/defaults.ts | 1 + src/config/types.ts | 1 + src/init.ts | 3 +++ test/endpoints/config.ts | 2 +- test/endpoints/int.spec.ts | 2 +- 5 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/config/defaults.ts b/src/config/defaults.ts index ff232e5ace..725312f825 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -7,6 +7,7 @@ export const defaults: Config = { maxDepth: 10, collections: [], globals: [], + endpoints: [], cookiePrefix: 'payload', csrf: [], cors: [], diff --git a/src/config/types.ts b/src/config/types.ts index 6a319ef0c1..e7e68fa635 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -200,6 +200,7 @@ export type Config = { export type SanitizedConfig = Omit, 'collections' | 'globals'> & { collections: SanitizedCollectionConfig[] globals: SanitizedGlobalConfig[] + endpoints: SanitizedGlobalConfig[] paths: { [key: string]: string }; } diff --git a/src/init.ts b/src/init.ts index 97b2b58001..1f5d7daeb8 100644 --- a/src/init.ts +++ b/src/init.ts @@ -31,6 +31,7 @@ import { Payload } from '.'; import loadConfig from './config/load'; import Logger from './utilities/logger'; import { getDataLoader } from './collections/dataloader'; +import mountEndpoints from './express/mountEndpoints'; export const init = (payload: Payload, options: InitOptions): void => { payload.logger.info('Starting Payload...'); @@ -105,6 +106,8 @@ export const init = (payload: Payload, options: InitOptions): void => { initGraphQLPlayground(payload); } + mountEndpoints(payload.router, payload.config.endpoints); + // Bind router to API payload.express.use(payload.config.routes.api, payload.router); diff --git a/test/endpoints/config.ts b/test/endpoints/config.ts index af2631af57..171a3690c2 100644 --- a/test/endpoints/config.ts +++ b/test/endpoints/config.ts @@ -71,7 +71,7 @@ export default buildConfig({ ], endpoints: [ { - path: applicationEndpoint, + path: `/${applicationEndpoint}`, method: 'post', handler: (req: PayloadRequest, res: Response): void => { res.json(req.body); diff --git a/test/endpoints/int.spec.ts b/test/endpoints/int.spec.ts index 5cb6f0c55f..404f1dcf49 100644 --- a/test/endpoints/int.spec.ts +++ b/test/endpoints/int.spec.ts @@ -46,7 +46,7 @@ describe('Endpoints', () => { }); }); - describe('Application', () => { + describe('API', () => { it('should call custom endpoint', async () => { const params = { app: 'response' }; const { status, data } = await client.endpoint(`/${applicationEndpoint}`, 'post', params); From dac33084411fc6b8c4a61debfa6de559b178aa9b Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Wed, 17 Aug 2022 14:08:01 -0400 Subject: [PATCH 038/130] docs: custom api endpoints --- docs/configuration/overview.mdx | 1 + docs/rest-api/overview.mdx | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/configuration/overview.mdx b/docs/configuration/overview.mdx index afb0fe87f2..a3cb53c0a1 100644 --- a/docs/configuration/overview.mdx +++ b/docs/configuration/overview.mdx @@ -39,6 +39,7 @@ Payload is a *config-based*, code-first CMS and application framework. The Paylo | `rateLimit` | Control IP-based rate limiting for all Payload resources. Used to prevent DDoS attacks and [more](/docs/production/preventing-abuse#rate-limiting-requests). | | `hooks` | Tap into Payload-wide hooks. [More](/docs/hooks/overview) | | `plugins` | An array of Payload plugins. [More](/docs/plugins/overview) | +| `endpoints` | An array of custom API endpoints added to the Payload router. [More](/docs/plugins/overview) | #### Simple example diff --git a/docs/rest-api/overview.mdx b/docs/rest-api/overview.mdx index f406a9d374..22291ab0fe 100644 --- a/docs/rest-api/overview.mdx +++ b/docs/rest-api/overview.mdx @@ -77,7 +77,7 @@ In addition to the dynamically generated endpoints above Payload also has REST e ## Custom Endpoints -Additional REST API endpoints can be added to `collections` and `globals` by providing array of `endpoints` in the configuration. These can be used to write additional middleware on existing routes or build custom functionality into Payload apps and plugins. +Additional REST API endpoints can be added to your application by providing an array of `endpoints` in various places within a Payload config. Custom endpoints are useful for adding additional middleware on existing routes or for building custom functionality into Payload apps and plugins. Endpoints can be added at the top of the Payload config, `collections`, and `globals` and accessed respective of the api and slugs you have configured. Each endpoint object needs to have: @@ -90,7 +90,6 @@ Each endpoint object needs to have: Example: ```js - // a collection of 'orders' with an additional route for tracking details, reachable at /api/orders/:id/tracking const Orders = { slug: 'orders', From fa01b353efc2bd50668c11f06e3dee4a7fb60a69 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 17 Aug 2022 15:31:39 -0700 Subject: [PATCH 039/130] chore: removes unused code --- src/config/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/config/types.ts b/src/config/types.ts index e7e68fa635..6a319ef0c1 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -200,7 +200,6 @@ export type Config = { export type SanitizedConfig = Omit, 'collections' | 'globals'> & { collections: SanitizedCollectionConfig[] globals: SanitizedGlobalConfig[] - endpoints: SanitizedGlobalConfig[] paths: { [key: string]: string }; } From 5e66e3ee78447a0cc01defa767b846c408bb93ee Mon Sep 17 00:00:00 2001 From: James Date: Wed, 17 Aug 2022 15:36:28 -0700 Subject: [PATCH 040/130] chore(release): v1.0.25 --- CHANGELOG.md | 13 +++++++++++++ package.json | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc17686a8a..e375f158f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## [1.0.25](https://github.com/payloadcms/payload/compare/v1.0.24...v1.0.25) (2022-08-17) + + +### Bug Fixes + +* [#568](https://github.com/payloadcms/payload/issues/568) ([a3edbf4](https://github.com/payloadcms/payload/commit/a3edbf4fef5efd8293cb4d6139b2513441cb741e)) + + +### Features + +* add new pickerAppearance option 'monthOnly' ([566c6ba](https://github.com/payloadcms/payload/commit/566c6ba3a9beb13ea9437844313ec6701effce27)) +* custom api endpoints ([11d8fc7](https://github.com/payloadcms/payload/commit/11d8fc71e8bdb62c6755789903702b0ee257b448)) + ## [1.0.24](https://github.com/payloadcms/payload/compare/v1.0.23...v1.0.24) (2022-08-16) diff --git a/package.json b/package.json index 43a64c0126..2cc45a9bd8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "1.0.24", + "version": "1.0.25", "description": "Node, React and MongoDB Headless CMS and Application Framework", "license": "MIT", "author": { From 39586d3cdb01131b29f1f8f7346086d2bc9903c1 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Thu, 18 Aug 2022 12:26:05 -0400 Subject: [PATCH 041/130] fix: missing fields in rows on custom id collections (#954) --- src/mongoose/buildSchema.ts | 2 +- test/collections-rest/config.ts | 25 +++++++++++++++++++++++-- test/collections-rest/int.spec.ts | 12 +++++++----- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index e0421fed40..c011b7d03a 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -54,7 +54,7 @@ const buildSchema = (config: SanitizedConfig, configFields: Field[], buildSchema fields = { _id: idField.type === 'number' ? Number : String, }; - schemaFields = schemaFields.filter((field) => fieldAffectsData(field) && field.name !== 'id'); + schemaFields = schemaFields.filter((field) => !(fieldAffectsData(field) && field.name === 'id')); } } diff --git a/test/collections-rest/config.ts b/test/collections-rest/config.ts index e471b4499e..f541effc81 100644 --- a/test/collections-rest/config.ts +++ b/test/collections-rest/config.ts @@ -101,8 +101,13 @@ export default buildConfig({ type: 'text', }, { - name: 'name', - type: 'text', + type: 'row', + fields: [ + { + name: 'name', + type: 'text', + }, + ], }, ], }, @@ -195,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', + }, + }); }, }); diff --git a/test/collections-rest/int.spec.ts b/test/collections-rest/int.spec.ts index 270a031c21..65a3f37568 100644 --- a/test/collections-rest/int.spec.ts +++ b/test/collections-rest/int.spec.ts @@ -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'); }); From f45d5a0421117180f85f8e3cd86f835c13ac6d16 Mon Sep 17 00:00:00 2001 From: Will Viles Date: Thu, 18 Aug 2022 18:17:05 +0100 Subject: [PATCH 042/130] feat: adds more prismjs syntax highlighting options for code blocks (#961) --- docs/fields/code.mdx | 14 +++++++++++++- .../components/forms/field-types/Code/Code.tsx | 7 +++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/fields/code.mdx b/docs/fields/code.mdx index ab9e3ed4cb..48866ddb29 100644 --- a/docs/fields/code.mdx +++ b/docs/fields/code.mdx @@ -39,7 +39,19 @@ This field uses `prismjs` for syntax highlighting and `react-simple-code-editor` In addition to the default [field admin config](/docs/fields/overview#admin-config), the Code field type also allows for the customization of a `language` property. -Currently, the `language` property only supports JavaScript syntax but more support will be added as requested. +The following `prismjs` plugins are imported, enabling the `language` property to accept the following values: + +| Plugin | Language | +| ---------------------------- | ----------- | +| **`prism-css`** | `css` | +| **`prism-clike`** | `clike` | +| **`prism-markup`** | `markup`, `html`, `xml`, `svg`, `mathml`, `ssml`, `atom`, `rss` | +| **`prism-javascript`** | `javascript`, `js` | +| **`prism-json`** | `json` | +| **`prism-jsx`** | `jsx` | +| **`prism-typescript`** | `typescript`, `ts` | +| **`prism-tsx`** | `tsx` | +| **`prism-yaml`** | `yaml`, `yml` | ### Example diff --git a/src/admin/components/forms/field-types/Code/Code.tsx b/src/admin/components/forms/field-types/Code/Code.tsx index 5a99de0592..3ca5ca50b7 100644 --- a/src/admin/components/forms/field-types/Code/Code.tsx +++ b/src/admin/components/forms/field-types/Code/Code.tsx @@ -2,7 +2,14 @@ import React, { useCallback, useState } from 'react'; import Editor from 'react-simple-code-editor'; import { highlight, languages } from 'prismjs/components/prism-core'; import 'prismjs/components/prism-clike'; +import 'prismjs/components/prism-css'; +import 'prismjs/components/prism-markup'; import 'prismjs/components/prism-javascript'; +import 'prismjs/components/prism-json'; +import 'prismjs/components/prism-jsx'; +import 'prismjs/components/prism-typescript'; +import 'prismjs/components/prism-tsx'; +import 'prismjs/components/prism-yaml'; import useField from '../../useField'; import withCondition from '../../withCondition'; import Label from '../../Label'; From 4e1f9c72800246885cba64aa87792e2e4a635a11 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Thu, 18 Aug 2022 14:44:10 -0400 Subject: [PATCH 043/130] test: add code fields to test config --- test/fields/collections/Code/index.tsx | 126 +++++++++++++++++++++++++ test/fields/config.ts | 3 + test/fields/payload-types.ts | 14 +++ 3 files changed, 143 insertions(+) create mode 100644 test/fields/collections/Code/index.tsx diff --git a/test/fields/collections/Code/index.tsx b/test/fields/collections/Code/index.tsx new file mode 100644 index 0000000000..837f5385cc --- /dev/null +++ b/test/fields/collections/Code/index.tsx @@ -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 = { + 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: ` + + + + + + +Prism + + + + + + + +`, + + 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; diff --git a/test/fields/config.ts b/test/fields/config.ts index 098efbba1c..910e11e83a 100644 --- a/test/fields/config.ts +++ b/test/fields/config.ts @@ -18,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: { @@ -35,6 +36,7 @@ export default buildConfig({ collections: [ ArrayFields, BlockFields, + CodeFields, CollapsibleFields, ConditionalLogic, GroupFields, @@ -69,6 +71,7 @@ export default buildConfig({ 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 }); diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index be91c47f53..fbb64ed0f3 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -112,6 +112,20 @@ export interface BlockField { 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; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "collapsible-fields". From 2cf9d35fed9120cfb5dc5831bea7a231645ac93e Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Thu, 18 Aug 2022 15:17:02 -0400 Subject: [PATCH 044/130] chore: run ci tests on pull request (#955) * chore: run ci tests on pull request --- .github/workflows/tests.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9470210480..d5434454ff 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,8 @@ name: build -on: [push] +on: + workflow_dispatch: + push: jobs: build_yarn: From 2b7785d101a2147a8b7feac8847d3457bb88980e Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Thu, 18 Aug 2022 16:22:35 -0400 Subject: [PATCH 045/130] chore: update endpoint type (#966) --- src/config/types.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/config/types.ts b/src/config/types.ts index 6a319ef0c1..032bdccf92 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1,4 +1,4 @@ -import { Express, Handler } from 'express'; +import { Express, NextFunction, Response } from 'express'; import { DeepRequired } from 'ts-essentials'; import { Transporter } from 'nodemailer'; import { Options } from 'express-fileupload'; @@ -79,10 +79,16 @@ export type AccessResult = boolean | Where; */ export type Access = (args?: any) => AccessResult | Promise; +interface PayloadHandler {( + req: PayloadRequest, + res: Response, + next: NextFunction, + ): void } + export type Endpoint = { path: string method: 'get' | 'head' | 'post' | 'put' | 'patch' | 'delete' | 'connect' | 'options' | string - handler: Handler | Handler[] + handler: PayloadHandler | PayloadHandler[] } export type AdminView = React.ComponentType<{ user: User, canAccessAdmin: boolean }> From 38a1a38c0c52403083458619b2f9b58044c5c0ea Mon Sep 17 00:00:00 2001 From: Afzaal Ahmad Date: Fri, 19 Aug 2022 01:59:33 +0500 Subject: [PATCH 046/130] feat: enable reordering of hasMany relationship and select fields (#952) * feature: enable reordering of hasMany relationship and select fields using config * update: correct docs for select, and relationship field for sortable config * update: move sortable to admin config, and rename it to isSortable, apply grab, and grabbing classes while drag, and drop --- docs/fields/relationship.mdx | 8 ++ docs/fields/select.mdx | 5 + package.json | 1 + .../elements/ReactSelect/index.scss | 4 + .../components/elements/ReactSelect/index.tsx | 94 ++++++++++++++++++- .../components/elements/ReactSelect/types.ts | 1 + .../Condition/Relationship/index.tsx | 3 +- .../forms/field-types/Relationship/index.tsx | 2 + .../forms/field-types/Select/Input.tsx | 3 + .../forms/field-types/Select/index.tsx | 2 + src/fields/config/schema.ts | 4 + src/fields/config/types.ts | 4 + src/utilities/arrayMove.ts | 9 ++ test/fields-relationship/config.ts | 23 ++++- test/fields/collections/Select/index.ts | 1 + yarn.lock | 20 +++- 16 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 src/utilities/arrayMove.ts diff --git a/docs/fields/relationship.mdx b/docs/fields/relationship.mdx index 60bdb8c9ac..8de1e75e95 100644 --- a/docs/fields/relationship.mdx +++ b/docs/fields/relationship.mdx @@ -45,6 +45,14 @@ keywords: relationship, fields, config, configuration, documentation, Content Ma The Depth parameter can be used to automatically populate related documents that are returned by the API. +### Admin config + +In addition to the default [field admin config](/docs/fields/overview#admin-config), the Relationship field type also allows for the following admin-specific properties: + +**`isSortable`** + +Set to `true` if you'd like this field to be sortable within the Admin UI using drag and drop. (Only works when `hasMany` is set to `true`) + ### Filtering relationship options Options can be dynamically limited by supplying a [query constraint](/docs/queries/overview), which will be used both for validating input and filtering available relationships in the UI. diff --git a/docs/fields/select.mdx b/docs/fields/select.mdx index ffdf2655d3..4322b36d78 100644 --- a/docs/fields/select.mdx +++ b/docs/fields/select.mdx @@ -51,6 +51,10 @@ In addition to the default [field admin config](/docs/fields/overview#admin-conf Set to `true` if you'd like this field to be clearable within the Admin UI. +**`isSortable`** + +Set to `true` if you'd like this field to be sortable within the Admin UI using drag and drop. (Only works when `hasMany` is set to `true`) + ### Example `collections/ExampleCollection.js` @@ -65,6 +69,7 @@ Set to `true` if you'd like this field to be clearable within the Admin UI. hasMany: true, admin: { isClearable: true, + isSortable: true, // use mouse to drag and drop different values, and sort them according to your choice }, options: [ { diff --git a/package.json b/package.json index 2cc45a9bd8..d6ae109253 100644 --- a/package.json +++ b/package.json @@ -274,6 +274,7 @@ "mongodb-memory-server": "^7.2.0", "nodemon": "^2.0.6", "passport-strategy": "^1.0.0", + "react-sortable-hoc": "^2.0.0", "release-it": "^14.2.2", "rimraf": "^3.0.2", "serve-static": "^1.14.2", diff --git a/src/admin/components/elements/ReactSelect/index.scss b/src/admin/components/elements/ReactSelect/index.scss index 7474ab676a..665ff8932e 100644 --- a/src/admin/components/elements/ReactSelect/index.scss +++ b/src/admin/components/elements/ReactSelect/index.scss @@ -89,6 +89,10 @@ div.react-select { border: $style-stroke-width-s solid var(--theme-elevation-800); line-height: calc(#{$baseline} - #{$style-stroke-width-s * 2}); margin: base(.25) base(.5) base(.25) 0; + + &.draggable { + cursor: grab; + } } .rs__multi-value__label { diff --git a/src/admin/components/elements/ReactSelect/index.tsx b/src/admin/components/elements/ReactSelect/index.tsx index 802a8cd783..6d98c83be4 100644 --- a/src/admin/components/elements/ReactSelect/index.tsx +++ b/src/admin/components/elements/ReactSelect/index.tsx @@ -1,10 +1,53 @@ -import React from 'react'; -import Select from 'react-select'; -import { Props } from './types'; +import React, { MouseEventHandler, useCallback } from 'react'; +import Select, { + components, + MultiValueProps, + Props as SelectProps, +} from 'react-select'; +import { + SortableContainer, + SortableContainerProps, + SortableElement, + SortStartHandler, + SortEndHandler, + SortableHandle, +} from 'react-sortable-hoc'; +import { arrayMove } from '../../../../utilities/arrayMove'; +import { Props, Value } from './types'; import Chevron from '../../icons/Chevron'; import './index.scss'; +const SortableMultiValue = SortableElement( + (props: MultiValueProps) => { + // this prevents the menu from being opened/closed when the user clicks + // on a value to begin dragging it. ideally, detecting a click (instead of + // a drag) would still focus the control and toggle the menu, but that + // requires some magic with refs that are out of scope for this example + const onMouseDown: MouseEventHandler = (e) => { + e.preventDefault(); + e.stopPropagation(); + }; + const classes = [ + props.className, + !props.isDisabled && 'draggable', + ].filter(Boolean).join(' '); + + return ( + + ); + }, +); + + +const SortableMultiValueLabel = SortableHandle((props) => ); + +const SortableSelect = SortableContainer(Select) as React.ComponentClass & SortableContainerProps>; + const ReactSelect: React.FC = (props) => { const { className, @@ -16,6 +59,8 @@ const ReactSelect: React.FC = (props) => { placeholder, isSearchable = true, isClearable, + isMulti, + isSortable, } = props; const classes = [ @@ -24,6 +69,49 @@ const ReactSelect: React.FC = (props) => { showError && 'react-select--error', ].filter(Boolean).join(' '); + const onSortStart: SortStartHandler = useCallback(({ helper }) => { + const portalNode = helper; + if (portalNode && portalNode.style) { + portalNode.style.cssText += 'pointer-events: auto; cursor: grabbing;'; + } + }, []); + + const onSortEnd: SortEndHandler = useCallback(({ oldIndex, newIndex }) => { + onChange(arrayMove(value as Value[], oldIndex, newIndex)); + }, [onChange, value]); + + if (isMulti && isSortable) { + return ( + node.getBoundingClientRect()} + // react-select props: + placeholder={placeholder} + {...props} + value={value as Value[]} + onChange={onChange} + disabled={disabled ? 'disabled' : undefined} + className={classes} + classNamePrefix="rs" + options={options} + isSearchable={isSearchable} + isClearable={isClearable} + components={{ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore We're failing to provide a required index prop to SortableElement + MultiValue: SortableMultiValue, + MultiValueLabel: SortableMultiValueLabel, + DropdownIndicator: Chevron, + }} + /> + ); + } + return ( setValue(e.target.value)} - value={value} - /> - ) + return setValue(e.target.value)} value={value.path} /> } ``` @@ -121,10 +125,10 @@ const CustomTextField = ({ path }) => { There are times when a custom field component needs to have access to data from other fields. This can be done using `getDataByPath` from `useWatchForm` as follows: -```js +```tsx import { useWatchForm } from 'payload/components/forms'; -const DisplayFee = () => { +const DisplayFee: React.FC = () => { const { getDataByPath } = useWatchForm(); const amount = getDataByPath('amount'); @@ -132,7 +136,7 @@ const DisplayFee = () => { if (amount && feePercentage) { return ( - The fee is ${ amount * feePercentage / 100 } + The fee is ${(amount * feePercentage) / 100} ); } }; @@ -142,10 +146,10 @@ const DisplayFee = () => { The document ID can be very useful for certain custom components. You can get the `id` from the `useDocumentInfo` hook. Here is an example of a `UI` field using `id` to link to related collections: -```js +```tsx import { useDocumentInfo } from 'payload/components/utilities'; -const LinkFromCategoryToPosts = () => { +const LinkFromCategoryToPosts: React.FC = () => { // highlight-start const { id } = useDocumentInfo(); // highlight-end @@ -222,10 +226,10 @@ To make use of Payload SCSS variables / mixins to use directly in your own compo In any custom component you can get the selected locale with the `useLocale` hook. Here is a simple example: -```js +```tsx import { useLocale } from 'payload/components/utilities'; -const Greeting = () => { +const Greeting: React.FC = () => { // highlight-start const locale = useLocale(); // highlight-end @@ -237,6 +241,6 @@ const Greeting = () => { return ( { trans[locale] } - ) -} + ); +}; ``` diff --git a/docs/admin/customizing-css.mdx b/docs/admin/customizing-css.mdx index f8aad29358..926307655d 100644 --- a/docs/admin/customizing-css.mdx +++ b/docs/admin/customizing-css.mdx @@ -13,7 +13,7 @@ You can add your own CSS by providing your base Payload config with a path to yo To do so, provide your base Payload config with a path to your own stylesheet. It can be either a CSS or SCSS file. **Example in payload.config.js:** -```js +```ts import { buildConfig } from 'payload/config'; import path from 'path'; @@ -21,7 +21,7 @@ const config = buildConfig({ admin: { css: path.resolve(__dirname, 'relative/path/to/stylesheet.scss'), }, -}) +}); ``` ### Overriding built-in styles diff --git a/docs/admin/overview.mdx b/docs/admin/overview.mdx index c3f0843ed7..cdc067097f 100644 --- a/docs/admin/overview.mdx +++ b/docs/admin/overview.mdx @@ -45,14 +45,14 @@ All options for the Admin panel are defined in your base Payload config file. To specify which Collection to use to log in to the Admin panel, pass the `admin` options a `user` key equal to the slug of the Collection that you'd like to use. `payload.config.js`: -```js +```ts import { buildConfig } from 'payload/config'; const config = buildConfig({ admin: { user: 'admins', // highlight-line }, -}) +}); ``` By default, if you have not specified a Collection, Payload will automatically provide you with a `User` Collection which will be used to access the Admin panel. You can customize or override the fields and settings of the default `User` Collection by passing your own collection using `users` as its `slug` to Payload. When this is done, Payload will use your provided `User` Collection instead of its default version. diff --git a/docs/admin/webpack.mdx b/docs/admin/webpack.mdx index 95983bea27..b48060b87d 100644 --- a/docs/admin/webpack.mdx +++ b/docs/admin/webpack.mdx @@ -10,8 +10,8 @@ Payload uses Webpack 5 to build the Admin panel. It comes with support for many To extend the Webpack config, add the `webpack` key to your base Payload config, and provide a function that accepts the default Webpack config as its only argument: -`payload.config.js` -```js +`payload.config.ts` +```ts import { buildConfig } from 'payload/config'; export default buildConfig({ @@ -24,7 +24,7 @@ export default buildConfig({ } // highlight-end } -}) +}); ``` ### Aliasing server-only modules @@ -52,16 +52,17 @@ You may rely on server-only packages such as the above to perform logic in acces

`collections/Subscriptions/index.js` -```js +```ts +import { CollectionConfig } from 'payload/types'; import createStripeSubscription from './hooks/createStripeSubscription'; -const Subscription = { +const Subscription: CollectionConfig = { slug: 'subscriptions', hooks: { beforeChange: [ createStripeSubscription, ] - } + }, fields: [ { name: 'stripeSubscriptionID', @@ -69,7 +70,7 @@ const Subscription = { required: true, } ] -} +}; export default Subscription; ``` diff --git a/docs/authentication/config.mdx b/docs/authentication/config.mdx index 5a84457630..aff7fa46a7 100644 --- a/docs/authentication/config.mdx +++ b/docs/authentication/config.mdx @@ -49,7 +49,7 @@ To utilize your API key while interacting with the REST or GraphQL API, add the **For example, using Fetch:** -```js +```ts const response = await fetch("http://localhost:3000/api/pages", { headers: { Authorization: `${collection.labels.singular} API-Key ${YOUR_API_KEY}`, @@ -77,8 +77,10 @@ Function that accepts one argument, containing `{ req, token, user }`, that allo Example: -```js -{ +```ts +import { CollectionConfig } from 'payload/types'; + +const Customers: CollectionConfig = { slug: 'customers', auth: { forgotPassword: { @@ -104,7 +106,7 @@ Example: // highlight-end } } -} +}; ``` @@ -123,7 +125,7 @@ Similarly to the above `generateEmailHTML`, you can also customize the subject o Example: -```js +```ts { slug: 'customers', auth: { @@ -148,8 +150,11 @@ Function that accepts one argument, containing `{ req, token, user }`, that allo Example: -```js -{ +```ts +import { CollectionConfig } from 'payload/types'; + + +const Customers: CollectionConfig = { slug: 'customers', auth: { verify: { @@ -163,7 +168,7 @@ Example: // highlight-end } } -} +}; ``` @@ -182,7 +187,7 @@ Similarly to the above `generateEmailHTML`, you can also customize the subject o Example: -```js +```ts { slug: 'customers', auth: { diff --git a/docs/authentication/operations.mdx b/docs/authentication/operations.mdx index b3cfb813e8..81735fda1b 100644 --- a/docs/authentication/operations.mdx +++ b/docs/authentication/operations.mdx @@ -17,7 +17,7 @@ The Access operation returns what a logged in user can and can't do with the col `GET http://localhost:3000/api/access` Example response: -```js +```ts { canAccessAdmin: true, collections: { @@ -54,7 +54,7 @@ Example response: **Example GraphQL Query**: -``` +```graphql query { Access { pages { @@ -75,7 +75,7 @@ Returns either a logged in user with token or null when there is no logged in us `GET http://localhost:3000/api/[collection-slug]/me` Example response: -```js +```ts { user: { // The JWT "payload" ;) from the logged in user email: 'dev@payloadcms.com', @@ -90,7 +90,7 @@ Example response: **Example GraphQL Query**: -``` +```graphql query { Me[collection-singular-label] { user { @@ -106,7 +106,7 @@ query { Accepts an `email` and `password`. On success, it will return the logged in user as well as a token that can be used to authenticate. In the GraphQL and REST APIs, this operation also automatically sets an HTTP-only cookie including the user's token. If you pass an Express `res` to the Local API operation, Payload will set a cookie there as well. **Example REST API login**: -```js +```ts const res = await fetch('http://localhost:3000/api/[collection-slug]/login', { method: 'POST', headers: { @@ -137,7 +137,7 @@ const json = await res.json(); **Example GraphQL Mutation**: -``` +```graphql mutation { login[collection-singular-label](email: "dev@payloadcms.com", password: "yikes") { user { @@ -151,7 +151,7 @@ mutation { **Example Local API login**: -```js +```ts const result = await payload.login({ collection: '[collection-slug]', data: { @@ -166,7 +166,7 @@ const result = await payload.login({ As Payload sets HTTP-only cookies, logging out cannot be done by just removing a cookie in JavaScript, as HTTP-only cookies are inaccessible by JS within the browser. So, Payload exposes a `logout` operation to delete the token in a safe way. **Example REST API logout**: -```js +```ts const res = await fetch('http://localhost:3000/api/[collection-slug]/logout', { method: 'POST', headers: { @@ -192,7 +192,7 @@ This operation requires a non-expired token to send back a new one. If the user' If successful, this operation will automatically renew the user's HTTP-only cookie and will send back the updated token in JSON. **Example REST API token refresh**: -```js +```ts const res = await fetch('http://localhost:3000/api/[collection-slug]/refresh-token', { method: 'POST', headers: { @@ -239,18 +239,18 @@ mutation { If your collection supports email verification, the Verify operation will be exposed which accepts a verification token and sets the user's `_verified` property to `true`, thereby allowing the user to authenticate with the Payload API. **Example REST API user verification**: -```js +```ts const res = await fetch(`http://localhost:3000/api/[collection-slug]/verify/${TOKEN_HERE}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, -}) +}); ``` **Example GraphQL Mutation**: -``` +```graphql mutation { verifyEmail[collection-singular-label](token: "TOKEN_HERE") } @@ -258,7 +258,7 @@ mutation { **Example Local API verification**: -```js +```ts const result = await payload.verifyEmail({ collection: '[collection-slug]', token: 'TOKEN_HERE', @@ -272,7 +272,7 @@ If a user locks themselves out and you wish to deliberately unlock them, you can To restrict who is allowed to unlock users, you can utilize the [`unlock`](/docs/access-control/overview#unlock) access control function. **Example REST API unlock**: -```js +```ts const res = await fetch(`http://localhost:3000/api/[collection-slug]/unlock`, { method: 'POST', headers: { @@ -291,7 +291,7 @@ mutation { **Example Local API unlock**: -```js +```ts const result = await payload.unlock({ collection: '[collection-slug]', }) @@ -306,7 +306,7 @@ The link to reset the user's password contains a token which is what allows the By default, the Forgot Password operations send users to the Payload Admin panel to reset their password, but you can customize the generated email to send users to the frontend of your app instead by [overriding the email HTML](/docs/authentication/config#forgot-password). **Example REST API Forgot Password**: -```js +```ts const res = await fetch(`http://localhost:3000/api/[collection-slug]/forgot-password`, { method: 'POST', headers: { @@ -315,7 +315,7 @@ const res = await fetch(`http://localhost:3000/api/[collection-slug]/forgot-pass body: JSON.stringify({ email: 'dev@payloadcms.com', }), -}) +}); ``` **Example GraphQL Mutation**: @@ -328,14 +328,14 @@ mutation { **Example Local API forgot password**: -```js +```ts const token = await payload.forgotPassword({ collection: '[collection-slug]', data: { email: 'dev@payloadcms.com', }, disableEmail: false // you can disable the auto-generation of email via local API -}) +}); ``` @@ -348,7 +348,7 @@ const token = await payload.forgotPassword({ After a user has "forgotten" their password and a token is generated, that token can be used to send to the reset password operation along with a new password which will allow the user to reset their password securely. **Example REST API Reset Password**: -```js +```ts const res = await fetch(`http://localhost:3000/api/[collection-slug]/reset-password`, { method: 'POST', headers: { @@ -358,7 +358,7 @@ const res = await fetch(`http://localhost:3000/api/[collection-slug]/reset-passw token: 'TOKEN_GOES_HERE' password: 'not-today', }), -}) +}); const json = await res.json(); @@ -379,7 +379,7 @@ const json = await res.json(); **Example GraphQL Mutation**: -``` +```graphql mutation { resetPassword[collection-singular-label](token: "TOKEN_GOES_HERE", password: "not-today") } diff --git a/docs/authentication/overview.mdx b/docs/authentication/overview.mdx index aad003d787..d4b392d593 100644 --- a/docs/authentication/overview.mdx +++ b/docs/authentication/overview.mdx @@ -32,8 +32,10 @@ Every Payload Collection can opt-in to supporting Authentication by specifying t Simple example collection: -```js -const Admins = { +```ts +import { CollectionConfig } from 'payload/types'; + +const Admins: CollectionConfig = { slug: // highlight-start auth: { @@ -95,7 +97,7 @@ However, if you use `fetch` or similar APIs to retrieve Payload resources from i Fetch example, including credentials: -```js +```ts const response = await fetch('http://localhost:3000/api/pages', { credentials: 'include', }); @@ -124,8 +126,8 @@ So, if a user of coolsite.com is logged in and just browsing around on the inter To define domains that should allow users to identify themselves via the Payload HTTP-only cookie, use the `csrf` option on the base Payload config to whitelist domains that you trust. -`payload.config.js`: -```js +`payload.config.ts`: +```ts import { buildConfig } from 'payload/config'; const config = buildConfig({ @@ -148,7 +150,7 @@ export default config; In addition to authenticating via an HTTP-only cookie, you can also identify users via the `Authorization` header on an HTTP request. Example: -```js +```ts const request = await fetch('http://localhost:3000', { headers: { Authorization: `JWT ${token}` diff --git a/docs/authentication/using-middleware.mdx b/docs/authentication/using-middleware.mdx index 1adbe4f3e8..e26b924e04 100644 --- a/docs/authentication/using-middleware.mdx +++ b/docs/authentication/using-middleware.mdx @@ -15,7 +15,7 @@ This approach has a ton of benefits - it's great for isolation of concerns and l Example in `server.js`: -```js +```ts import express from 'express'; import payload from 'payload'; diff --git a/docs/configuration/collections.mdx b/docs/configuration/collections.mdx index 44bb5a6431..1fb4f40e72 100644 --- a/docs/configuration/collections.mdx +++ b/docs/configuration/collections.mdx @@ -31,8 +31,10 @@ It's often best practice to write your Collections in separate files and then im #### Simple collection example -```js -const Orders = { +```ts +import { CollectionConfig } from 'payload/types'; + +const Orders: CollectionConfig = { slug: 'orders', fields: [ { @@ -47,7 +49,7 @@ const Orders = { required: true, } ] -} +}; ``` #### More collection config examples @@ -80,8 +82,10 @@ If the function is specified, a Preview button will automatically appear in the **Example collection with preview function:** -```js -const Posts = { +```ts +import { CollectionConfig } from 'payload/types'; + +const Posts: CollectionConfig = { slug: 'posts', fields: [ { @@ -118,14 +122,14 @@ Collections support all field types that Payload has to offer—including simple You can import collection types as follows: -```js +```ts import { CollectionConfig } from 'payload/types'; // This is the type used for incoming collection configs. // Only the bare minimum properties are marked as required. ``` -```js +```ts import { SanitizedCollectionConfig } from 'payload/types'; // This is the type used after an incoming collection config is fully sanitized. diff --git a/docs/configuration/globals.mdx b/docs/configuration/globals.mdx index dcd53dfa39..6bd5b9487c 100644 --- a/docs/configuration/globals.mdx +++ b/docs/configuration/globals.mdx @@ -28,8 +28,10 @@ As with Collection configs, it's often best practice to write your Globals in se #### Simple Global example -```js -const Nav = { +```ts +import { GlobalConfig } from 'payload/types'; + +const Nav: GlobalConfig = { slug: 'nav', fields: [ { @@ -47,7 +49,9 @@ const Nav = { ] }, ] -} +}; + +export default Nav; ``` #### Global config example @@ -78,14 +82,14 @@ Globals support all field types that Payload has to offer—including simple fie You can import global types as follows: -```js +```ts import { GlobalConfig } from 'payload/types'; // This is the type used for incoming global configs. // Only the bare minimum properties are marked as required. ``` -```js +```ts import { SanitizedGlobalConfig } from 'payload/types'; // This is the type used after an incoming global config is fully sanitized. diff --git a/docs/configuration/localization.mdx b/docs/configuration/localization.mdx index 6348f0d016..a2f7779d93 100644 --- a/docs/configuration/localization.mdx +++ b/docs/configuration/localization.mdx @@ -14,21 +14,23 @@ Add the `localization` property to your Payload config to enable localization pr **Example Payload config set up for localization:** -```js -{ - collections: [ - ... // collections go here - ], - localization: { - locales: [ - 'en', - 'es', - 'de', - ], - defaultLocale: 'en', - fallback: true, - }, -} +```ts +import { buildConfig } from 'payload/config' + +export default buildConfig({ + collections: [ + // collections go here + ], + localization: { + locales: [ + 'en', + 'es', + 'de', + ], + defaultLocale: 'en', + fallback: true, + }, +}); ``` **Here is a brief explanation of each of the options available within the `localization` property:** @@ -53,11 +55,11 @@ Payload localization works on a **field** level—not a document level. In addit ```js { - name: 'title', - type: 'text', - // highlight-start - localized: true, - // highlight-end + name: 'title', + type: 'text', + // highlight-start + localized: true, + // highlight-end } ``` @@ -66,8 +68,8 @@ With the above configuration, the `title` field will now be saved in the databas All field types with a `name` property support the `localized` property—even the more complex field types like `array`s and `block`s. - Note:
- Enabling localization for field types that support nested fields will automatically create localized "sets" of all fields contained within the field. For example, if you have a page layout using a blocks field type, you have the choice of either localizing the full layout, by enabling localization on the top-level blocks field, or only certain fields within the layout. + Note:
+ Enabling localization for field types that support nested fields will automatically create localized "sets" of all fields contained within the field. For example, if you have a page layout using a blocks field type, you have the choice of either localizing the full layout, by enabling localization on the top-level blocks field, or only certain fields within the layout.
### Retrieving localized docs @@ -104,16 +106,16 @@ The `fallbackLocale` arg will accept valid locales as well as `none` to disable ```graphql query { - Posts(locale: de, fallbackLocale: none) { - docs { - title - } - } + Posts(locale: de, fallbackLocale: none) { + docs { + title + } + } } ``` - In GraphQL, specifying the locale at the top level of a query will automatically apply it throughout all nested relationship fields. You can override this behavior by re-specifying locale arguments in nested related document queries. + In GraphQL, specifying the locale at the top level of a query will automatically apply it throughout all nested relationship fields. You can override this behavior by re-specifying locale arguments in nested related document queries. ##### Local API @@ -124,9 +126,9 @@ You can specify `locale` as well as `fallbackLocale` within the Local API as wel ```js const posts = await payload.find({ - collection: 'posts', - locale: 'es', - fallbackLocale: false, + collection: 'posts', + locale: 'es', + fallbackLocale: false, }) ``` diff --git a/docs/configuration/overview.mdx b/docs/configuration/overview.mdx index a3cb53c0a1..d013467ade 100644 --- a/docs/configuration/overview.mdx +++ b/docs/configuration/overview.mdx @@ -43,10 +43,10 @@ Payload is a *config-based*, code-first CMS and application framework. The Paylo #### Simple example -```js +```ts import { buildConfig } from 'payload/config'; -const config = buildConfig({ +export default buildConfig({ collections: [ { slug: 'pages', @@ -83,9 +83,6 @@ const config = buildConfig({ } ] }); - -export default config; - ``` #### Full example config @@ -174,14 +171,14 @@ Then, you could import this file into both your Payload config and your server, You can import config types as follows: -```js +```ts import { Config } from 'payload/config'; // This is the type used for an incoming Payload config. // Only the bare minimum properties are marked as required. ``` -```js +```ts import { SanitizedConfig } from 'payload/config'; // This is the type used after an incoming Payload config is fully sanitized. diff --git a/docs/email/overview.mdx b/docs/email/overview.mdx index a19199fbb6..420323919a 100644 --- a/docs/email/overview.mdx +++ b/docs/email/overview.mdx @@ -40,7 +40,7 @@ The following options are configurable in the `email` property object as part of Simple Mail Transfer Protocol, also known as SMTP can be passed in using the `transportOptions` object on the `email` options. **Example email part using SMTP:** -```js +```ts payload.init({ email: { transportOptions: { @@ -60,6 +60,7 @@ payload.init({ fromAddress: 'hello@example.com' } // ... +}) ``` @@ -70,9 +71,9 @@ payload.init({ Many third party mail providers are available and offer benefits beyond basic SMTP. As an example your payload init could look this if you wanted to use SendGrid.com though the same approach would work for any other [NodeMailer transports](https://nodemailer.com/transports/) shown here or provided by another third party. -```js -const nodemailerSendgrid = require('nodemailer-sendgrid'); -const payload = require('payload'); +```ts +import payload from 'payload' +import nodemailerSendgrid from 'nodemailer-sendgrid' const sendGridAPIKey = process.env.SENDGRID_API_KEY; @@ -92,7 +93,10 @@ payload.init({ ### Use a custom NodeMailer transport To take full control of the mail transport you may wish to use `nodemailer.createTransport()` on your server and provide it to Payload init. -```js +```ts +import payload from 'payload' +import nodemailer from 'nodemailer' + const payload = require('payload'); const nodemailer = require('nodemailer'); @@ -112,7 +116,7 @@ payload.init({ transport }, // ... -} +}); ``` ### Sending Mail @@ -123,7 +127,7 @@ By default, Payload uses a mock implementation that only sends mail to the [ethe To see ethereal credentials, add `logMockCredentials: true` to the email options. This will cause them to be logged to console on startup. -```js +```ts payload.init({ email: { fromName: 'Admin', diff --git a/docs/fields/array.mdx b/docs/fields/array.mdx index 00392da0bc..ec934c5587 100644 --- a/docs/fields/array.mdx +++ b/docs/fields/array.mdx @@ -41,9 +41,11 @@ keywords: array, fields, config, configuration, documentation, Content Managemen ### Example -`collections/ExampleCollection.js` -```js -{ +`collections/ExampleCollection.ts` +```ts +import { CollectionConfig } from 'payload/types'; + +const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { @@ -70,6 +72,5 @@ keywords: array, fields, config, configuration, documentation, Content Managemen ] } ] -} - +}; ``` diff --git a/docs/fields/blocks.mdx b/docs/fields/blocks.mdx index 7944417a74..be6baecb33 100644 --- a/docs/fields/blocks.mdx +++ b/docs/fields/blocks.mdx @@ -72,8 +72,10 @@ The Admin panel provides each block with a `blockName` field which optionally al ### Example `collections/ExampleCollection.js` -```js -const QuoteBlock = { +```ts +import { Block, CollectionConfig } from 'payload/types'; + +const QuoteBlock: Block = { slug: 'Quote', // required imageURL: 'https://google.com/path/to/image.jpg', imageAltText: 'A nice thumbnail image to show what this block looks like', @@ -90,7 +92,7 @@ const QuoteBlock = { ] }; -const ExampleCollection = { +const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { @@ -111,7 +113,7 @@ const ExampleCollection = { As you build your own Block configs, you might want to store them in separate files but retain typing accordingly. To do so, you can import and use Payload's `Block` type: -```js +```ts import type { Block } from 'payload/types'; ``` diff --git a/docs/fields/checkbox.mdx b/docs/fields/checkbox.mdx index d7b3703b2b..1e419676b2 100644 --- a/docs/fields/checkbox.mdx +++ b/docs/fields/checkbox.mdx @@ -31,9 +31,11 @@ keywords: checkbox, fields, config, configuration, documentation, Content Manage ### Example -`collections/ExampleCollection.js` -```js -{ +`collections/ExampleCollection.ts` +```ts +import { CollectionConfig } from 'payload/types'; + +const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { @@ -43,6 +45,5 @@ keywords: checkbox, fields, config, configuration, documentation, Content Manage defaultValue: false, } ] -} - +}; ``` diff --git a/docs/fields/code.mdx b/docs/fields/code.mdx index 48866ddb29..f7985523af 100644 --- a/docs/fields/code.mdx +++ b/docs/fields/code.mdx @@ -55,9 +55,11 @@ The following `prismjs` plugins are imported, enabling the `language` property t ### Example -`collections/ExampleCollection.js` -```js -{ +`collections/ExampleCollection.ts +```ts +import { CollectionConfig } from 'payload/types'; + +const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { @@ -69,6 +71,5 @@ The following `prismjs` plugins are imported, enabling the `language` property t } } ] -} - +}; ``` diff --git a/docs/fields/collapsible.mdx b/docs/fields/collapsible.mdx index e33e1fbea3..e990f0def7 100644 --- a/docs/fields/collapsible.mdx +++ b/docs/fields/collapsible.mdx @@ -22,9 +22,11 @@ keywords: row, fields, config, configuration, documentation, Content Management ### Example -`collections/ExampleCollection.js` -```js -{ +`collections/ExampleCollection.ts` +```ts +import { CollectionConfig } from 'payload/types'; + +const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { @@ -39,6 +41,5 @@ keywords: row, fields, config, configuration, documentation, Content Management ], } ] -} - +}; ``` diff --git a/docs/fields/date.mdx b/docs/fields/date.mdx index 54c1858d39..60057c7b6a 100644 --- a/docs/fields/date.mdx +++ b/docs/fields/date.mdx @@ -55,10 +55,12 @@ Common use cases for customizing the `date` property are to restrict your field ### Example -`collections/ExampleCollection.js` +`collections/ExampleCollection.ts` -```js -{ +```ts +import { CollectionConfig } from 'payload/types'; + +const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { @@ -74,6 +76,5 @@ Common use cases for customizing the `date` property are to restrict your field } } ] -} - +}; ``` diff --git a/docs/fields/email.mdx b/docs/fields/email.mdx index 87d4ea677e..b2d895259e 100644 --- a/docs/fields/email.mdx +++ b/docs/fields/email.mdx @@ -44,9 +44,11 @@ Set this property to a string that will be used for browser autocomplete. ### Example -`collections/ExampleCollection.js` -```js -{ +`collections/ExampleCollection.ts` +```ts +import { CollectionConfig } from 'payload/types'; + +const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { @@ -56,6 +58,5 @@ Set this property to a string that will be used for browser autocomplete. required: true, } ] -} - +}; ``` diff --git a/docs/fields/group.mdx b/docs/fields/group.mdx index 11946adf58..aa90f6fff1 100644 --- a/docs/fields/group.mdx +++ b/docs/fields/group.mdx @@ -39,9 +39,11 @@ Set this property to `true` to hide this field's gutter within the admin panel. ### Example -`collections/ExampleCollection.js` -```js -{ +`collections/ExampleCollection.ts` +```ts +import { CollectionConfig } from 'payload/types'; + +const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { @@ -65,6 +67,5 @@ Set this property to `true` to hide this field's gutter within the admin panel. ], } ] -} - +}; ``` diff --git a/docs/fields/number.mdx b/docs/fields/number.mdx index 0aa80a3f6d..2023080f24 100644 --- a/docs/fields/number.mdx +++ b/docs/fields/number.mdx @@ -50,9 +50,11 @@ Set this property to a string that will be used for browser autocomplete. ### Example -`collections/ExampleCollection.js` -```js -{ +`collections/ExampleCollection.ts` +```ts +import { CollectionConfig } from 'payload/types'; + +const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { @@ -64,6 +66,5 @@ Set this property to a string that will be used for browser autocomplete. } } ] -} - +}; ``` diff --git a/docs/fields/overview.mdx b/docs/fields/overview.mdx index 30d6f316d0..681d73e260 100644 --- a/docs/fields/overview.mdx +++ b/docs/fields/overview.mdx @@ -16,8 +16,10 @@ Fields are defined as an array on Collections and Globals via the `fields` key. The required `type` property on a field determines what values it can accept, how it is presented in the API, and how the field will be rendered in the admin interface. **Simple collection with two fields:** -```js -const Pages = { +```ts +import { CollectionConfig } from 'payload/types'; + +const Page: CollectionConfig = { slug: 'pages', fields: [ { @@ -29,7 +31,7 @@ const Pages = { type: 'checkbox', // highlight-line }, ], -} +}; ``` ### Field types @@ -80,8 +82,10 @@ There are two arguments available to custom validation functions. | `payload` | If the `validate` function is being executed on the server, Payload will be exposed for easily running local operations. | Example: -```js -{ +```ts +import { CollectionConfig } from 'payload/types'; + +const Orders: CollectionConfig = { slug: 'orders', fields: [ { @@ -101,27 +105,27 @@ Example: }, }, ], -} +}; ``` When supplying a field `validate` function, Payload will use yours in place of the default. To make use of the default field validation in your custom logic you can import, call and return the result as needed. For example: -```js +```ts import { text } from 'payload/fields/validations'; -const field = - { - name: 'notBad', - type: 'text', - validate: (val, args) => { - if (value === 'bad') { - return 'This cannot be "bad"'; - } - // highlight-start - return text(val, args); - // highlight-end - }, -} + +const field: Field = { + name: 'notBad', + type: 'text', + validate: (val, args) => { + if (val === 'bad') { + return 'This cannot be "bad"'; + } + // highlight-start + return text(val, args); + // highlight-end + }, +}; ``` ### Customizable ID @@ -131,7 +135,7 @@ Users are then required to provide a custom ID value when creating a record thro Valid ID types are `number` and `text`. Example: -```js +```ts { fields: [ { @@ -174,7 +178,7 @@ The `condition` function should return a boolean that will control if the field **Example:** -```js +```ts { fields: [ { @@ -212,21 +216,21 @@ Functions are called with an optional argument object containing: Here is an example of a defaultValue function that uses both: -```js +```ts const translation: { - en: 'Written by', - es: 'Escrito por', + en: 'Written by', + es: 'Escrito por', }; const field = { - name: 'attribution', - type: 'text', - admin: { - // highlight-start - defaultValue: ({ user, locale }) => (`${translation[locale]} ${user.name}`) - // highlight-end - } - }; + name: 'attribution', + type: 'text', + admin: { + // highlight-start + defaultValue: ({ user, locale }) => (`${translation[locale]} ${user.name}`) + // highlight-end + } +}; ``` @@ -244,7 +248,7 @@ As shown above, you can simply provide a string that will show by the field, but **Function Example:** -```js +```ts { fields: [ { @@ -262,7 +266,7 @@ As shown above, you can simply provide a string that will show by the field, but This example will display the number of characters allowed as the user types. **Component Example:** -```js +```ts { fields: [ { @@ -289,10 +293,8 @@ This component will count the number of characters entered. You can import the internal Payload `Field` type as well as other common field types as follows: -```js +```ts import type { Field, - Validate, - Condition, } from 'payload/types'; ``` diff --git a/docs/fields/point.mdx b/docs/fields/point.mdx index 74fe7759c9..1fa134a8a4 100644 --- a/docs/fields/point.mdx +++ b/docs/fields/point.mdx @@ -35,9 +35,11 @@ The data structure in the database matches the GeoJSON structure to represent po ### Example -`collections/ExampleCollection.js` -```js -{ +`collections/ExampleCollection.ts` +```ts +import { CollectionConfig } from 'payload/types'; + +const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { @@ -46,7 +48,7 @@ The data structure in the database matches the GeoJSON structure to represent po label: 'Location', }, ] -} +}; ``` ### Querying diff --git a/docs/fields/radio.mdx b/docs/fields/radio.mdx index a82a850664..1918016bd7 100644 --- a/docs/fields/radio.mdx +++ b/docs/fields/radio.mdx @@ -45,9 +45,11 @@ The `layout` property allows for the radio group to be styled as a horizonally o ### Example -`collections/ExampleCollection.js` -```js -{ +`collections/ExampleCollection.ts` +```ts +import { CollectionConfig } from 'payload/types'; + +const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { diff --git a/docs/fields/relationship.mdx b/docs/fields/relationship.mdx index 8de1e75e95..a11f079176 100644 --- a/docs/fields/relationship.mdx +++ b/docs/fields/relationship.mdx @@ -69,26 +69,26 @@ The `filterOptions` property can either be a `Where` query directly, or a functi **Example:** -```js - const relationshipField = { - name: 'purchase', - type: 'relationship', - relationTo: ['products', 'services'], - filterOptions: ({ relationTo, siblingData }) => { - // returns a Where query dynamically by the type of relationship - if (relationTo === 'products') { - return { - 'stock': { is_greater_than: siblingData.quantity } - } +```ts +const relationshipField = { + name: 'purchase', + type: 'relationship', + relationTo: ['products', 'services'], + filterOptions: ({ relationTo, siblingData }) => { + // returns a Where query dynamically by the type of relationship + if (relationTo === 'products') { + return { + 'stock': { is_greater_than: siblingData.quantity } } + } - if (relationTo === 'services') { - return { - 'isAvailable': { equals: true } - } + if (relationTo === 'services') { + return { + 'isAvailable': { equals: true } } - }, - }; + } + }, +}; ``` You can learn more about writing queries [here](/docs/queries/overview). @@ -106,7 +106,7 @@ Given the variety of options possible within the `relationship` field type, the The most simple pattern of a relationship is to use `hasMany: false` with a `relationTo` that allows for only one type of collection. -```js +```ts { slug: 'example-collection', fields: [ @@ -137,7 +137,7 @@ When querying documents in this collection via REST API, you could query as foll Also known as **dynamic references**, in this configuration, the `relationTo` field is an array of Collection slugs that tells Payload which Collections are valid to reference. -```js +```ts { slug: 'example-collection', fields: [ @@ -176,7 +176,7 @@ This query would return only documents that have an owner relationship to organi The `hasMany` tells Payload that there may be more than one collection saved to the field. -```js +```ts { slug: 'example-collection', fields: [ @@ -204,7 +204,7 @@ When querying documents, the format does not change for arrays: #### Has Many - Polymorphic -```js +```ts { slug: 'example-collection', fields: [ diff --git a/docs/fields/rich-text.mdx b/docs/fields/rich-text.mdx index ec27fb3c59..88eb225992 100644 --- a/docs/fields/rich-text.mdx +++ b/docs/fields/rich-text.mdx @@ -126,9 +126,11 @@ Custom `Leaf` objects follow a similar pattern but require you to define the `Le ### Example -`collections/ExampleCollection.js` -```js -{ +`collections/ExampleCollection.ts` +```ts +import { CollectionConfig } from 'payload/types'; + +const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { @@ -178,7 +180,7 @@ Custom `Leaf` objects follow a similar pattern but require you to define the `Le } } ] -} +}; ``` For more examples regarding how to define your own elements and leaves, check out the example [`RichText` field](https://github.com/payloadcms/public-demo/blob/master/src/fields/hero.ts) within the Public Demo source code. @@ -187,7 +189,7 @@ For more examples regarding how to define your own elements and leaves, check ou As the Rich Text field saves its content in a JSON format, you'll need to render it as HTML yourself. Here is an example for how to generate JSX / HTML from Rich Text content: -```js +```ts import React, { Fragment } from 'react'; import escapeHTML from 'escape-html'; import { Text } from 'slate'; @@ -308,7 +310,7 @@ If you want to utilize this functionality within your own custom elements, you c `customLargeBodyElement.js`: -```js +```ts import Button from './Button'; import Element from './Element'; import withLargeBody from './plugin'; @@ -338,7 +340,7 @@ The plugin itself extends Payload's built-in `shouldBreakOutOnEnter` Slate funct If you are building your own custom Rich Text elements or leaves, you may benefit from importing the following types: -```js +```ts import type { RichTextCustomElement, RichTextCustomLeaf, diff --git a/docs/fields/row.mdx b/docs/fields/row.mdx index b06d439c21..9823aeacd4 100644 --- a/docs/fields/row.mdx +++ b/docs/fields/row.mdx @@ -21,9 +21,11 @@ keywords: row, fields, config, configuration, documentation, Content Management ### Example -`collections/ExampleCollection.js` -```js -{ +`collections/ExampleCollection.ts` +```ts +import { CollectionConfig } from 'payload/types'; + +const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { diff --git a/docs/fields/select.mdx b/docs/fields/select.mdx index 4322b36d78..9df50056ca 100644 --- a/docs/fields/select.mdx +++ b/docs/fields/select.mdx @@ -57,10 +57,11 @@ Set to `true` if you'd like this field to be sortable within the Admin UI using ### Example -`collections/ExampleCollection.js` +`collections/ExampleCollection.ts` +```ts +import { CollectionConfig } from 'payload/types'; -```js -{ +const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { diff --git a/docs/fields/tabs.mdx b/docs/fields/tabs.mdx index 184ccecf53..5c59252b04 100644 --- a/docs/fields/tabs.mdx +++ b/docs/fields/tabs.mdx @@ -34,9 +34,11 @@ Each tab has its own required `label` and `fields` array. You can also optionall ### Example -`collections/ExampleCollection.js` -```js -{ +`collections/ExampleCollection.ts` +```ts +import { CollectionConfig } from 'payload/types'; + +const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { diff --git a/docs/fields/text.mdx b/docs/fields/text.mdx index af6345337a..a5bf0770a2 100644 --- a/docs/fields/text.mdx +++ b/docs/fields/text.mdx @@ -46,9 +46,11 @@ Set this property to a string that will be used for browser autocomplete. ### Example -`collections/ExampleCollection.js` -```js -{ +`collections/ExampleCollection.ts` +```ts +import { CollectionConfig } from 'payload/types'; + +const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { diff --git a/docs/fields/textarea.mdx b/docs/fields/textarea.mdx index 2814f43b1e..bbc0eccb87 100644 --- a/docs/fields/textarea.mdx +++ b/docs/fields/textarea.mdx @@ -46,9 +46,11 @@ Set this property to a string that will be used for browser autocomplete. ### Example -`collections/ExampleCollection.js` -```js -{ +`collections/ExampleCollection.ts` +```ts +import { CollectionConfig } from 'payload/types'; + +const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { diff --git a/docs/fields/ui.mdx b/docs/fields/ui.mdx index 9ee48eda66..89737a40f7 100644 --- a/docs/fields/ui.mdx +++ b/docs/fields/ui.mdx @@ -34,9 +34,11 @@ With this field, you can also inject custom `Cell` components that appear as add ### Example -`collections/ExampleCollection.js` -```js -{ +`collections/ExampleCollection.ts` +```ts +import { CollectionConfig } from 'payload/types'; + +const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { diff --git a/docs/fields/upload.mdx b/docs/fields/upload.mdx index dfcc2ed75f..90e7a16f0f 100644 --- a/docs/fields/upload.mdx +++ b/docs/fields/upload.mdx @@ -46,9 +46,11 @@ keywords: upload, images media, fields, config, configuration, documentation, Co ### Example -`collections/ExampleCollection.js` -```js -{ +`collections/ExampleCollection.ts` +```ts +import { CollectionConfig } from 'payload/types'; + +const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { diff --git a/docs/graphql/extending.mdx b/docs/graphql/extending.mdx index d9b7da6133..e6c723e769 100644 --- a/docs/graphql/extending.mdx +++ b/docs/graphql/extending.mdx @@ -33,7 +33,7 @@ Both `graphQL.queries` and `graphQL.mutations` functions should return an object `payload.config.js`: -```js +```ts import { buildConfig } from 'payload/config'; import myCustomQueryResolver from './graphQL/resolvers/myCustomQueryResolver'; diff --git a/docs/graphql/overview.mdx b/docs/graphql/overview.mdx index e83eb1fb60..cbc31b8c19 100644 --- a/docs/graphql/overview.mdx +++ b/docs/graphql/overview.mdx @@ -29,8 +29,10 @@ At the top of your Payload config you can define all the options to manage Graph Everything that can be done to a Collection via the REST or Local API can be done with GraphQL (outside of uploading files, which is REST-only). If you have a collection as follows: -```js -const PublicUser = { +```ts +import { CollectionConfig } from 'payload/types'; + +const PublicUser: CollectionConfig = { slug: 'public-users', auth: true, // Auth is enabled labels: { @@ -70,8 +72,10 @@ const PublicUser = { Globals are also fully supported. For example: -```js -const Header = { +```ts +import { GlobalConfig } from 'payload/types'; + +const Header: GlobalConfig = { slug: 'header', fields: [ ... diff --git a/docs/hooks/collections.mdx b/docs/hooks/collections.mdx index e74bf95569..9cac9ed8ab 100644 --- a/docs/hooks/collections.mdx +++ b/docs/hooks/collections.mdx @@ -30,10 +30,11 @@ Additionally, `auth`-enabled collections feature the following hooks: All collection Hook properties accept arrays of synchronous or asynchronous functions. Each Hook type receives specific arguments and has the ability to modify specific outputs. -`collections/example-hooks.js` -```js -// Collection config -module.exports = { +`collections/exampleHooks.js` +```ts +import { CollectionConfig } from 'payload/types'; + +const ExampleHooks: CollectionConfig = { slug: 'example-hooks', fields: [ { name: 'name', type: 'text'}, @@ -65,8 +66,10 @@ The `beforeOperation` Hook type can be used to modify the arguments that operati Available Collection operations include `create`, `read`, `update`, `delete`, `login`, `refresh` and `forgotPassword`. -```js -const beforeOperationHook = async ({ +```ts +import { CollectionBeforeOperationHook } from 'payload/types'; + +const beforeOperationHook: CollectionBeforeOperationHook = async ({ args, // Original arguments passed into the operation operation, // name of the operation }) => { @@ -78,8 +81,10 @@ const beforeOperationHook = async ({ Runs before the `create` and `update` operations. This hook allows you to add or format data before the incoming data is validated. -```js -const beforeValidateHook = async ({ +```ts +import { CollectionBeforeOperationHook } from 'payload/types'; + +const beforeValidateHook: CollectionBeforeValidateHook = async ({ data, // incoming data to update or create with req, // full express request operation, // name of the operation ie. 'create', 'update' @@ -93,8 +98,10 @@ const beforeValidateHook = async ({ Immediately following validation, `beforeChange` hooks will run within `create` and `update` operations. At this stage, you can be confident that the data that will be saved to the document is valid in accordance to your field validations. You can optionally modify the shape of data to be saved. -```js -const beforeChangeHook = async ({ +```ts +import { CollectionBeforeChangeHook } from 'payload/types'; + +const beforeChangeHook: CollectionBeforeChangeHook = async ({ data, // incoming data to update or create with req, // full express request operation, // name of the operation ie. 'create', 'update' @@ -108,8 +115,10 @@ const beforeChangeHook = async ({ After a document is created or updated, the `afterChange` hook runs. This hook is helpful to recalculate statistics such as total sales within a global, syncing user profile changes to a CRM, and more. -```js -const afterChangeHook = async ({ +```ts +import { CollectionAfterChangeHook } from 'payload/types'; + +const afterChangeHook: CollectionAfterChangeHook = async ({ doc, // full document data req, // full express request operation, // name of the operation ie. 'create', 'update' @@ -122,8 +131,10 @@ const afterChangeHook = async ({ Runs before `find` and `findByID` operations are transformed for output by `afterRead`. This hook fires before hidden fields are removed and before localized fields are flattened into the requested locale. Using this Hook will provide you with all locales and all hidden fields via the `doc` argument. -```js -const beforeReadHook = async ({ +```ts +import { CollectionBeforeReadHook } from 'payload/types'; + +const beforeReadHook: CollectionBeforeReadHook = async ({ doc, // full document data req, // full express request query, // JSON formatted query @@ -136,8 +147,10 @@ const beforeReadHook = async ({ Runs as the last step before documents are returned. Flattens locales, hides protected fields, and removes fields that users do not have access to. -```js -const afterReadHook = async ({ +```ts +import { CollectionAfterReadHook } from 'payload/types'; + +const afterReadHook: CollectionAfterReadHook = async ({ doc, // full document data req, // full express request query, // JSON formatted query @@ -151,8 +164,10 @@ const afterReadHook = async ({ Runs before the `delete` operation. Returned values are discarded. -```js -const beforeDeleteHook = async ({ +```ts +import { CollectionBeforeDeleteHook } from 'payload/types'; + +const beforeDeleteHook: CollectionBeforeDeleteHook = async ({ req, // full express request id, // id of document to delete }) => {...} @@ -162,8 +177,10 @@ const beforeDeleteHook = async ({ Runs immediately after the `delete` operation removes records from the database. Returned values are discarded. -```js -const afterDeleteHook = async ({ +```ts +import { CollectionAfterDeleteHook } from 'payload/types'; + +const afterDeleteHook: CollectionAfterDeleteHook = async ({ req, // full express request id, // id of document to delete doc, // deleted document @@ -174,8 +191,10 @@ const afterDeleteHook = async ({ For auth-enabled Collections, this hook runs after successful `login` operations. You can optionally modify the user that is returned. -```js -const beforeLoginHook = async ({ +```ts +import { CollectionBeforeLoginHook } from 'payload/types'; + +const beforeLoginHook: CollectionBeforeLoginHook = async ({ req, // full express request user, // user being logged in token, // user token @@ -188,8 +207,10 @@ const beforeLoginHook = async ({ For auth-enabled Collections, this hook runs after successful `login` operations. You can optionally modify the user that is returned. -```js -const afterLoginHook = async ({ +```ts +import { CollectionAfterLoginHook } from 'payload/types'; + +const afterLoginHook: CollectionAfterLoginHook = async ({ req, // full express request }) => {...} ``` @@ -198,8 +219,10 @@ const afterLoginHook = async ({ For auth-enabled Collections, this hook runs after `logout` operations. -```js -const afterLogoutHook = async ({ +```ts +import { CollectionAfterLogoutHook } from 'payload/types'; + +const afterLogoutHook: CollectionAfterLogoutHook = async ({ req, // full express request }) => {...} ``` @@ -208,8 +231,10 @@ const afterLogoutHook = async ({ For auth-enabled Collections, this hook runs after `refresh` operations. -```js -const afterRefreshHook = async ({ +```ts +import { CollectionAfterRefreshHook } from 'payload/types'; + +const afterRefreshHook: CollectionAfterRefreshHook = async ({ req, // full express request res, // full express response token, // newly refreshed user token @@ -220,8 +245,10 @@ const afterRefreshHook = async ({ For auth-enabled Collections, this hook runs after `me` operations. -```js -const afterMeHook = async ({ +```ts +import { CollectionAfterMeHook } from 'payload/types'; + +const afterMeHook: CollectionAfterMeHook = async ({ req, // full express request response, // response to return }) => {...} @@ -231,8 +258,10 @@ const afterMeHook = async ({ For auth-enabled Collections, this hook runs after successful `forgotPassword` operations. Returned values are discarded. -```js -const afterLoginHook = async ({ +```ts +import { CollectionAfterForgotPasswordHook } from 'payload/types'; + +const afterLoginHook: CollectionAfterForgotPasswordHook = async ({ req, // full express request user, // user being logged in token, // user token @@ -245,7 +274,7 @@ const afterLoginHook = async ({ Payload exports a type for each Collection hook which can be accessed as follows: -```js +```ts import type { CollectionBeforeOperationHook, CollectionBeforeValidateHook, @@ -262,7 +291,4 @@ import type { CollectionAfterMeHook, CollectionAfterForgotPasswordHook, } from 'payload/types'; - -// Use hook types here... -} ``` diff --git a/docs/hooks/fields.mdx b/docs/hooks/fields.mdx index 22480e29c8..6dbec80125 100644 --- a/docs/hooks/fields.mdx +++ b/docs/hooks/fields.mdx @@ -26,8 +26,10 @@ Field-level hooks offer incredible potential for encapsulating your logic. They ## Config Example field configuration: -```js -{ +```ts +import { CollectionConfig } from 'payload/types'; + +const ExampleCollection: CollectionConfig = { name: 'name', type: 'text', // highlight-start @@ -77,7 +79,7 @@ All field hooks can optionally modify the return value of the field before the o Payload exports a type for field hooks which can be accessed and used as follows: -```js +```ts import type { FieldHook } from 'payload/types'; // Field hook type is a generic that takes three arguments: diff --git a/docs/hooks/globals.mdx b/docs/hooks/globals.mdx index 1e4824139c..034d00db9b 100644 --- a/docs/hooks/globals.mdx +++ b/docs/hooks/globals.mdx @@ -19,9 +19,10 @@ Globals feature the ability to define the following hooks: All Global Hook properties accept arrays of synchronous or asynchronous functions. Each Hook type receives specific arguments and has the ability to modify specific outputs. `globals/example-hooks.js` -```js -// Global config -module.exports = { +```ts +import { GlobalConfig } from 'payload/types'; + +const ExampleHooks: GlobalConfig = { slug: 'header', fields: [ { name: 'title', type: 'text'}, @@ -40,8 +41,10 @@ module.exports = { Runs before the `update` operation. This hook allows you to add or format data before the incoming data is validated. -```js -const beforeValidateHook = async ({ +```ts +import { GlobalBeforeValidateHook } from 'payload/types' + +const beforeValidateHook: GlobalBeforeValidateHook = async ({ data, // incoming data to update or create with req, // full express request originalDoc, // original document @@ -54,8 +57,10 @@ const beforeValidateHook = async ({ Immediately following validation, `beforeChange` hooks will run within the `update` operation. At this stage, you can be confident that the data that will be saved to the document is valid in accordance to your field validations. You can optionally modify the shape of data to be saved. -```js -const beforeChangeHook = async ({ +```ts +import { GlobalBeforeChangeHook } from 'payload/types' + +const beforeChangeHook: GlobalBeforeChangeHook = async ({ data, // incoming data to update or create with req, // full express request originalDoc, // original document @@ -68,8 +73,10 @@ const beforeChangeHook = async ({ After a global is updated, the `afterChange` hook runs. Use this hook to purge caches of your applications, sync site data to CRMs, and more. -```js -const afterChangeHook = async ({ +```ts +import { GlobalAfterChangeHook } from 'payload/types' + +const afterChangeHook: GlobalAfterChangeHook = async ({ doc, // full document data req, // full express request }) => { @@ -81,8 +88,10 @@ const afterChangeHook = async ({ Runs before `findOne` global operation is transformed for output by `afterRead`. This hook fires before hidden fields are removed and before localized fields are flattened into the requested locale. Using this Hook will provide you with all locales and all hidden fields via the `doc` argument. -```js -const beforeReadHook = async ({ +```ts +import { GlobalBeforeReadHook } from 'payload/types' + +const beforeReadHook: GlobalBeforeReadHook = async ({ doc, // full document data req, // full express request }) => {...} @@ -92,8 +101,10 @@ const beforeReadHook = async ({ Runs as the last step before a global is returned. Flattens locales, hides protected fields, and removes fields that users do not have access to. -```js -const afterReadHook = async ({ +```ts +import { GlobalAfterReadHook } from 'payload/types' + +const afterReadHook: GlobalAfterReadHook = async ({ doc, // full document data req, // full express request findMany, // boolean to denote if this hook is running against finding one, or finding many (useful in versions) @@ -104,7 +115,7 @@ const afterReadHook = async ({ Payload exports a type for each Global hook which can be accessed as follows: -```js +```ts import type { GlobalBeforeValidateHook, GlobalBeforeChangeHook, @@ -112,7 +123,4 @@ import type { GlobalBeforeReadHook, GlobalAfterReadHook, } from 'payload/types'; - -// Use hook types here... -} ``` diff --git a/docs/local-api/overview.mdx b/docs/local-api/overview.mdx index 93240fc62c..cd9fbe15c9 100644 --- a/docs/local-api/overview.mdx +++ b/docs/local-api/overview.mdx @@ -28,10 +28,11 @@ You can gain access to the currently running `payload` object via two ways: You can import or require `payload` into your own files after it's been initialized, but you need to make sure that your `import` / `require` statements come **after** you call `payload.init()`—otherwise Payload won't have been initialized yet. That might be obvious. To us, it's usually not. Example: -```js +```ts import payload from 'payload'; +import { CollectionAfterChangeHook } from 'payload/types'; -const afterChangeHook = async () => { +const afterChangeHook: CollectionAfterChangeHook = async () => { const posts = await payload.find({ collection: 'posts', }); @@ -43,8 +44,8 @@ const afterChangeHook = async () => { Payload is available anywhere you have access to the Express `req` - including within your access control and hook functions. Example: -```js -const afterChangeHook = async ({ req: { payload }}) => { +```ts +const afterChangeHook: CollectionAfterChangeHook = async ({ req: { payload }}) => { const posts = await payload.find({ collection: 'posts', }); @@ -319,3 +320,25 @@ const result = await payload.updateGlobal({ showHiddenFields: true, }) ``` + +## TypeScript + +Local API calls also support passing in a generic. This is especially useful if you generate your TS types using a [generate types script](/docs/typescript/generate-types). + +Here is an example of usage: + +```ts +// Our generated types +import { Post } from './payload-types' + +// Add Post types as generic to create function +const post: Post = await payload.create({ + collection: 'posts', + + // Data will now be typed as Post and give you type hints + data: { + title: 'my title', + description: 'my description', + }, +}) +``` diff --git a/docs/plugins/overview.mdx b/docs/plugins/overview.mdx index b7110d023e..5582f5bf97 100644 --- a/docs/plugins/overview.mdx +++ b/docs/plugins/overview.mdx @@ -83,11 +83,10 @@ After all plugins are executed, the full config with all plugins will be sanitiz Here is an example for how to automatically add a `lastModifiedBy` field to all Payload collections using a Plugin written in TypeScript. -```js -import { Config } from 'payload/config'; -import { CollectionConfig } from 'payload/dist/collections/config/types'; +```ts +import { Config, Plugin } from 'payload/config'; -const addLastModified = (incomingConfig: Config): Config => { +const addLastModified: Plugin = (incomingConfig: Config): Config => { // Find all incoming auth-enabled collections // so we can create a lastModifiedBy relationship field // to all auth collections diff --git a/docs/production/deployment.mdx b/docs/production/deployment.mdx index 91ef84f9d4..940bb9818f 100644 --- a/docs/production/deployment.mdx +++ b/docs/production/deployment.mdx @@ -37,7 +37,7 @@ Because _**you**_ are in complete control of who can do what with your data, you Before running in Production, you need to have built a production-ready copy of the Payload Admin panel. To do this, Payload provides the `build` NPM script. You can use it by adding a `script` to your `package.json` file like this: `package.json`: -```js +```json { "name": "project-name-here", "scripts": { diff --git a/docs/queries/overview.mdx b/docs/queries/overview.mdx index e6c65893de..c8bf326ff7 100644 --- a/docs/queries/overview.mdx +++ b/docs/queries/overview.mdx @@ -16,8 +16,10 @@ Payload provides an extremely granular querying language through all APIs. Each For example, say you have a collection as follows: -```js -const Post = { +```ts +import { CollectionConfig } from 'payload/types'; + +const Post: CollectionConfig = { slug: 'posts', fields: [ { diff --git a/docs/queries/pagination.mdx b/docs/queries/pagination.mdx index 665cfe9751..ef9cbd2999 100644 --- a/docs/queries/pagination.mdx +++ b/docs/queries/pagination.mdx @@ -24,7 +24,7 @@ All collection `find` queries are paginated automatically. Responses are returne | nextPage | `number` of next page, `null` if it doesn't exist | **Example response:** -```js +```json { // Document Array // highlight-line "docs": [ diff --git a/docs/rest-api/overview.mdx b/docs/rest-api/overview.mdx index 22291ab0fe..57d8dad1db 100644 --- a/docs/rest-api/overview.mdx +++ b/docs/rest-api/overview.mdx @@ -89,9 +89,11 @@ Each endpoint object needs to have: Example: -```js +```ts +import { CollectionConfig } from 'payload/types'; + // a collection of 'orders' with an additional route for tracking details, reachable at /api/orders/:id/tracking -const Orders = { +const Orders: CollectionConfig = { slug: 'orders', fields: [ /* ... */ ], // highlight-start diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx index 3a8bc4a52a..e31aed7a38 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -55,38 +55,40 @@ _An asterisk denotes that a property above is required._ **Example Upload collection:** -```js -const Media = { - slug: "media", +```ts +import { CollectionConfig } from 'payload/types'; + +const Media: CollectionConfig = { + slug: 'media', upload: { - staticURL: "/media", - staticDir: "media", + staticURL: '/media', + staticDir: 'media', imageSizes: [ { - name: "thumbnail", + name: 'thumbnail', width: 400, height: 300, - crop: "centre", + crop: 'centre', }, { - name: "card", + name: 'card', width: 768, height: 1024, - crop: "centre", + crop: 'centre', }, { - name: "tablet", + name: 'tablet', width: 1024, // By specifying `null` or leaving a height undefined, // the image will be sized to a certain width, // but it will retain its original aspect ratio // and calculate a height automatically. height: null, - crop: "centre", + crop: 'centre', }, ], - adminThumbnail: "thumbnail", - mimeTypes: ["image/*"], + adminThumbnail: 'thumbnail', + mimeTypes: ['image/*'], }, }; ``` @@ -97,17 +99,17 @@ Payload relies on the [`express-fileupload`](https://www.npmjs.com/package/expre A common example of what you might want to customize within Payload-wide Upload options would be to increase the allowed `fileSize` of uploads sent to Payload: -```js -import { buildConfig } from "payload/config"; +```ts +import { buildConfig } from 'payload/config'; export default buildConfig({ collections: [ { - slug: "media", + slug: 'media', fields: [ { - name: "alt", - type: "text", + name: 'alt', + type: 'text', }, ], upload: true, @@ -158,12 +160,14 @@ You can specify how Payload retrieves admin thumbnails for your upload-enabled C **Example custom Admin thumbnail:** -```js -const Media = { - slug: "media", +```ts +import { CollectionConfig } from 'payload/types'; + +const Media: CollectionConfig = { + slug: 'media', upload: { - staticURL: "/media", - staticDir: "media", + staticURL: '/media', + staticDir: 'media', imageSizes: [ // ... image sizes here ], @@ -191,13 +195,15 @@ Some example values are: `image/*`, `audio/*`, `video/*`, `image/png`, `applicat **Example mimeTypes usage:** -```js -const Media = { - slug: "media", +```ts +import { CollectionConfig } from 'payload/types'; + +const Media: CollectionConfig = { + slug: 'media', upload: { - staticURL: "/media", - staticDir: "media", - mimeTypes: ["image/*", "application/pdf"], // highlight-line + staticURL: '/media', + staticDir: 'media', + mimeTypes: ['image/*', 'application/pdf'], // highlight-line }, }; ``` diff --git a/docs/versions/autosave.mdx b/docs/versions/autosave.mdx index 3717a752d5..f2bc2b7d6e 100644 --- a/docs/versions/autosave.mdx +++ b/docs/versions/autosave.mdx @@ -25,8 +25,10 @@ Collections and Globals both support the same options for configuring autosave. **Example config with versions, drafts, and autosave enabled:** -```js -const Pages = { +```ts +import { CollectionConfig } from 'payload/types'; + +const Pages: CollectionConfig = { slug: 'pages', access: { read: ({ req }) => { diff --git a/docs/versions/drafts.mdx b/docs/versions/drafts.mdx index 0f92e4fa14..7cffd18f07 100644 --- a/docs/versions/drafts.mdx +++ b/docs/versions/drafts.mdx @@ -81,8 +81,10 @@ You can use the `read` [Access Control](/docs/access-control/collections#read) m Here is an example that utilizes the `_status` field to require a user to be logged in to retrieve drafts: -```js -const Pages = { +```ts +import { CollectionConfig } from 'payload/types'; + +const Pages: CollectionConfig = { slug: 'pages', access: { read: ({ req }) => { @@ -114,8 +116,10 @@ const Pages = { Here is an example for how to write an access control function that grants access to both documents where `_status` is equal to "published" and where `_status` does not exist: -```js -const Pages = { +```ts +import { CollectionConfig } from 'payload/types'; + +const Pages: CollectionConfig = { slug: 'pages', access: { read: ({ req }) => { diff --git a/docs/versions/overview.mdx b/docs/versions/overview.mdx index 0adeaf92ba..13872c0ec9 100644 --- a/docs/versions/overview.mdx +++ b/docs/versions/overview.mdx @@ -77,7 +77,7 @@ _slug_versions Each document in this new `versions` collection will store a set of meta properties about the version as well as a _full_ copy of the document. For example, a version's data might look like this for a Collection document: -```js +```json { "_id": "61cf752c19cdf1b1af7b61f1", // a unique ID of this version "parent": "61ce1354091d5b3ffc20ea6e", // the ID of the parent document From 25f5d68b74b081c060ddf6f0405c9211f5da6b54 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Tue, 23 Aug 2022 13:26:00 -0400 Subject: [PATCH 054/130] feat: export more fields config types and validation type (#989) --- types.d.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/types.d.ts b/types.d.ts index ba811ff270..1b2c685cb5 100644 --- a/types.d.ts +++ b/types.d.ts @@ -34,4 +34,25 @@ export { RichTextCustomElement, RichTextCustomLeaf, Block, + TextField, + NumberField, + EmailField, + TextareaField, + CheckboxField, + DateField, + BlockField, + GroupField, + RadioField, + RelationshipField, + ArrayField, + RichTextField, + SelectField, + UploadField, + CodeField, + PointField, + RowField, + CollapsibleField, + TabsField, + UIField, + Validate, } from './dist/fields/config/types'; From 4900fa799ffbeb70e689622b269dc04a67978552 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Tue, 23 Aug 2022 15:41:52 -0400 Subject: [PATCH 055/130] fix: remove lazy loading of array and blocks --- .../forms/field-types/Array/Array.tsx | 350 --------------- .../forms/field-types/Array/index.tsx | 355 ++++++++++++++- .../forms/field-types/Blocks/Blocks.tsx | 417 ----------------- .../forms/field-types/Blocks/index.tsx | 422 +++++++++++++++++- 4 files changed, 759 insertions(+), 785 deletions(-) delete mode 100644 src/admin/components/forms/field-types/Array/Array.tsx delete mode 100644 src/admin/components/forms/field-types/Blocks/Blocks.tsx diff --git a/src/admin/components/forms/field-types/Array/Array.tsx b/src/admin/components/forms/field-types/Array/Array.tsx deleted file mode 100644 index 2e7723493d..0000000000 --- a/src/admin/components/forms/field-types/Array/Array.tsx +++ /dev/null @@ -1,350 +0,0 @@ -import React, { useCallback, useEffect, useReducer, useState } from 'react'; -import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd'; -import { useAuth } from '../../../utilities/Auth'; -import withCondition from '../../withCondition'; -import Button from '../../../elements/Button'; -import reducer, { Row } from '../rowReducer'; -import { useForm } from '../../Form/context'; -import buildStateFromSchema from '../../Form/buildStateFromSchema'; -import useField from '../../useField'; -import { useLocale } from '../../../utilities/Locale'; -import Error from '../../Error'; -import { array } from '../../../../../fields/validations'; -import Banner from '../../../elements/Banner'; -import FieldDescription from '../../FieldDescription'; -import { useDocumentInfo } from '../../../utilities/DocumentInfo'; -import { useOperation } from '../../../utilities/OperationProvider'; -import { Collapsible } from '../../../elements/Collapsible'; -import RenderFields from '../../RenderFields'; -import { fieldAffectsData } from '../../../../../fields/config/types'; -import { Props } from './types'; -import { usePreferences } from '../../../utilities/Preferences'; -import { ArrayAction } from '../../../elements/ArrayAction'; -import { scrollToID } from '../../../../utilities/scrollToID'; -import HiddenInput from '../HiddenInput'; - -import './index.scss'; - -const baseClass = 'array-field'; - -const ArrayFieldType: React.FC = (props) => { - const { - name, - path: pathFromProps, - fields, - fieldTypes, - validate = array, - required, - maxRows, - minRows, - permissions, - admin: { - readOnly, - description, - condition, - className, - }, - } = props; - - const path = pathFromProps || name; - - // Handle labeling for Arrays, Global Arrays, and Blocks - const getLabels = (p: Props) => { - if (p?.labels) return p.labels; - if (p?.label) return { singular: p.label, plural: undefined }; - return { singular: 'Row', plural: 'Rows' }; - }; - - const labels = getLabels(props); - // eslint-disable-next-line react/destructuring-assignment - const label = props?.label ?? props?.labels?.singular; - - const { preferencesKey } = useDocumentInfo(); - const { getPreference } = usePreferences(); - const { setPreference } = usePreferences(); - const [rows, dispatchRows] = useReducer(reducer, undefined); - const formContext = useForm(); - const { user } = useAuth(); - const { id } = useDocumentInfo(); - const locale = useLocale(); - const operation = useOperation(); - - const { dispatchFields } = formContext; - - const memoizedValidate = useCallback((value, options) => { - return validate(value, { ...options, minRows, maxRows, required }); - }, [maxRows, minRows, required, validate]); - - const [disableFormData, setDisableFormData] = useState(false); - - const { - showError, - errorMessage, - value, - setValue, - } = useField({ - path, - validate: memoizedValidate, - disableFormData, - condition, - }); - - const addRow = useCallback(async (rowIndex: number) => { - const subFieldState = await buildStateFromSchema({ fieldSchema: fields, operation, id, user, locale }); - dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path }); - dispatchRows({ type: 'ADD', rowIndex }); - setValue(value as number + 1); - - setTimeout(() => { - scrollToID(`${path}-row-${rowIndex + 1}`); - }, 0); - }, [dispatchRows, dispatchFields, fields, path, setValue, value, operation, id, user, locale]); - - const duplicateRow = useCallback(async (rowIndex: number) => { - dispatchFields({ type: 'DUPLICATE_ROW', rowIndex, path }); - dispatchRows({ type: 'ADD', rowIndex }); - setValue(value as number + 1); - - setTimeout(() => { - scrollToID(`${path}-row-${rowIndex + 1}`); - }, 0); - }, [dispatchRows, dispatchFields, path, setValue, value]); - - const removeRow = useCallback((rowIndex: number) => { - dispatchRows({ type: 'REMOVE', rowIndex }); - dispatchFields({ type: 'REMOVE_ROW', rowIndex, path }); - setValue(value as number - 1); - }, [dispatchRows, dispatchFields, path, value, setValue]); - - const moveRow = useCallback((moveFromIndex: number, moveToIndex: number) => { - dispatchRows({ type: 'MOVE', moveFromIndex, moveToIndex }); - dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path }); - }, [dispatchRows, dispatchFields, path]); - - const onDragEnd = useCallback((result) => { - if (!result.destination) return; - const sourceIndex = result.source.index; - const destinationIndex = result.destination.index; - moveRow(sourceIndex, destinationIndex); - }, [moveRow]); - - const setCollapse = useCallback(async (rowID: string, collapsed: boolean) => { - dispatchRows({ type: 'SET_COLLAPSE', id: rowID, collapsed }); - - if (preferencesKey) { - const preferencesToSet = await getPreference(preferencesKey) || { fields: {} }; - let newCollapsedState = preferencesToSet?.fields?.[path]?.collapsed - .filter((filterID) => (rows.find((row) => row.id === filterID))) - || []; - - if (!collapsed) { - newCollapsedState = newCollapsedState.filter((existingID) => existingID !== rowID); - } else { - newCollapsedState.push(rowID); - } - - setPreference(preferencesKey, { - ...preferencesToSet, - fields: { - ...preferencesToSet?.fields || {}, - [path]: { - ...preferencesToSet?.fields?.[path], - collapsed: newCollapsedState, - }, - }, - }); - } - }, [preferencesKey, path, setPreference, rows, getPreference]); - - const toggleCollapseAll = useCallback(async (collapse: boolean) => { - dispatchRows({ type: 'SET_ALL_COLLAPSED', collapse }); - - if (preferencesKey) { - const preferencesToSet = await getPreference(preferencesKey) || { fields: {} }; - - setPreference(preferencesKey, { - ...preferencesToSet, - fields: { - ...preferencesToSet?.fields || {}, - [path]: { - ...preferencesToSet?.fields?.[path], - collapsed: collapse ? rows.map(({ id: rowID }) => rowID) : [], - }, - }, - }); - } - }, [path, getPreference, preferencesKey, rows, setPreference]); - - useEffect(() => { - const initializeRowState = async () => { - const data = formContext.getDataByPath(path); - const preferences = await getPreference(preferencesKey) || { fields: {} }; - dispatchRows({ type: 'SET_ALL', data: data || [], collapsedState: preferences?.fields?.[path]?.collapsed }); - }; - - initializeRowState(); - }, [formContext, path, getPreference, preferencesKey]); - - useEffect(() => { - setValue(rows?.length || 0, true); - - if (rows?.length === 0) { - setDisableFormData(false); - } else { - setDisableFormData(true); - } - }, [rows, setValue]); - - const hasMaxRows = maxRows && rows?.length >= maxRows; - - const classes = [ - 'field-type', - baseClass, - className, - ].filter(Boolean).join(' '); - - if (!rows) return null; - - return ( - -
-
- -
-
-
-

{label}

-
    -
  • - -
  • -
  • - -
  • -
-
- -
- - {(provided) => ( -
- {rows.length > 0 && rows.map((row, i) => { - const rowNumber = i + 1; - - return ( - - {(providedDrag) => ( -
- setCollapse(row.id, collapsed)} - className={`${baseClass}__row`} - key={row.id} - dragHandleProps={providedDrag.dragHandleProps} - header={`${labels.singular} ${rowNumber >= 10 ? rowNumber : `0${rowNumber}`}`} - actions={!readOnly ? ( - - ) : undefined} - > - - ({ - ...field, - path: `${path}.${i}${fieldAffectsData(field) ? `.${field.name}` : ''}`, - }))} - /> - - -
- )} -
- ); - })} - {(rows.length < minRows || (required && rows.length === 0)) && ( - - This field requires at least - {' '} - {minRows - ? `${minRows} ${labels.plural}` - : `1 ${labels.singular}`} - - )} - {(rows.length === 0 && readOnly) && ( - - This field has no - {' '} - {labels.plural} - . - - )} - {provided.placeholder} -
- )} -
- {(!readOnly && !hasMaxRows) && ( -
- -
- )} -
-
- ); -}; - -export default withCondition(ArrayFieldType); diff --git a/src/admin/components/forms/field-types/Array/index.tsx b/src/admin/components/forms/field-types/Array/index.tsx index 831fe49a87..2e7723493d 100644 --- a/src/admin/components/forms/field-types/Array/index.tsx +++ b/src/admin/components/forms/field-types/Array/index.tsx @@ -1,13 +1,350 @@ -import React, { Suspense, lazy } from 'react'; -import Loading from '../../../elements/Loading'; +import React, { useCallback, useEffect, useReducer, useState } from 'react'; +import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd'; +import { useAuth } from '../../../utilities/Auth'; +import withCondition from '../../withCondition'; +import Button from '../../../elements/Button'; +import reducer, { Row } from '../rowReducer'; +import { useForm } from '../../Form/context'; +import buildStateFromSchema from '../../Form/buildStateFromSchema'; +import useField from '../../useField'; +import { useLocale } from '../../../utilities/Locale'; +import Error from '../../Error'; +import { array } from '../../../../../fields/validations'; +import Banner from '../../../elements/Banner'; +import FieldDescription from '../../FieldDescription'; +import { useDocumentInfo } from '../../../utilities/DocumentInfo'; +import { useOperation } from '../../../utilities/OperationProvider'; +import { Collapsible } from '../../../elements/Collapsible'; +import RenderFields from '../../RenderFields'; +import { fieldAffectsData } from '../../../../../fields/config/types'; import { Props } from './types'; +import { usePreferences } from '../../../utilities/Preferences'; +import { ArrayAction } from '../../../elements/ArrayAction'; +import { scrollToID } from '../../../../utilities/scrollToID'; +import HiddenInput from '../HiddenInput'; -const ArrayField = lazy(() => import('./Array')); +import './index.scss'; -const ArrayFieldType: React.FC = (props) => ( - }> - - -); +const baseClass = 'array-field'; -export default ArrayFieldType; +const ArrayFieldType: React.FC = (props) => { + const { + name, + path: pathFromProps, + fields, + fieldTypes, + validate = array, + required, + maxRows, + minRows, + permissions, + admin: { + readOnly, + description, + condition, + className, + }, + } = props; + + const path = pathFromProps || name; + + // Handle labeling for Arrays, Global Arrays, and Blocks + const getLabels = (p: Props) => { + if (p?.labels) return p.labels; + if (p?.label) return { singular: p.label, plural: undefined }; + return { singular: 'Row', plural: 'Rows' }; + }; + + const labels = getLabels(props); + // eslint-disable-next-line react/destructuring-assignment + const label = props?.label ?? props?.labels?.singular; + + const { preferencesKey } = useDocumentInfo(); + const { getPreference } = usePreferences(); + const { setPreference } = usePreferences(); + const [rows, dispatchRows] = useReducer(reducer, undefined); + const formContext = useForm(); + const { user } = useAuth(); + const { id } = useDocumentInfo(); + const locale = useLocale(); + const operation = useOperation(); + + const { dispatchFields } = formContext; + + const memoizedValidate = useCallback((value, options) => { + return validate(value, { ...options, minRows, maxRows, required }); + }, [maxRows, minRows, required, validate]); + + const [disableFormData, setDisableFormData] = useState(false); + + const { + showError, + errorMessage, + value, + setValue, + } = useField({ + path, + validate: memoizedValidate, + disableFormData, + condition, + }); + + const addRow = useCallback(async (rowIndex: number) => { + const subFieldState = await buildStateFromSchema({ fieldSchema: fields, operation, id, user, locale }); + dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path }); + dispatchRows({ type: 'ADD', rowIndex }); + setValue(value as number + 1); + + setTimeout(() => { + scrollToID(`${path}-row-${rowIndex + 1}`); + }, 0); + }, [dispatchRows, dispatchFields, fields, path, setValue, value, operation, id, user, locale]); + + const duplicateRow = useCallback(async (rowIndex: number) => { + dispatchFields({ type: 'DUPLICATE_ROW', rowIndex, path }); + dispatchRows({ type: 'ADD', rowIndex }); + setValue(value as number + 1); + + setTimeout(() => { + scrollToID(`${path}-row-${rowIndex + 1}`); + }, 0); + }, [dispatchRows, dispatchFields, path, setValue, value]); + + const removeRow = useCallback((rowIndex: number) => { + dispatchRows({ type: 'REMOVE', rowIndex }); + dispatchFields({ type: 'REMOVE_ROW', rowIndex, path }); + setValue(value as number - 1); + }, [dispatchRows, dispatchFields, path, value, setValue]); + + const moveRow = useCallback((moveFromIndex: number, moveToIndex: number) => { + dispatchRows({ type: 'MOVE', moveFromIndex, moveToIndex }); + dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path }); + }, [dispatchRows, dispatchFields, path]); + + const onDragEnd = useCallback((result) => { + if (!result.destination) return; + const sourceIndex = result.source.index; + const destinationIndex = result.destination.index; + moveRow(sourceIndex, destinationIndex); + }, [moveRow]); + + const setCollapse = useCallback(async (rowID: string, collapsed: boolean) => { + dispatchRows({ type: 'SET_COLLAPSE', id: rowID, collapsed }); + + if (preferencesKey) { + const preferencesToSet = await getPreference(preferencesKey) || { fields: {} }; + let newCollapsedState = preferencesToSet?.fields?.[path]?.collapsed + .filter((filterID) => (rows.find((row) => row.id === filterID))) + || []; + + if (!collapsed) { + newCollapsedState = newCollapsedState.filter((existingID) => existingID !== rowID); + } else { + newCollapsedState.push(rowID); + } + + setPreference(preferencesKey, { + ...preferencesToSet, + fields: { + ...preferencesToSet?.fields || {}, + [path]: { + ...preferencesToSet?.fields?.[path], + collapsed: newCollapsedState, + }, + }, + }); + } + }, [preferencesKey, path, setPreference, rows, getPreference]); + + const toggleCollapseAll = useCallback(async (collapse: boolean) => { + dispatchRows({ type: 'SET_ALL_COLLAPSED', collapse }); + + if (preferencesKey) { + const preferencesToSet = await getPreference(preferencesKey) || { fields: {} }; + + setPreference(preferencesKey, { + ...preferencesToSet, + fields: { + ...preferencesToSet?.fields || {}, + [path]: { + ...preferencesToSet?.fields?.[path], + collapsed: collapse ? rows.map(({ id: rowID }) => rowID) : [], + }, + }, + }); + } + }, [path, getPreference, preferencesKey, rows, setPreference]); + + useEffect(() => { + const initializeRowState = async () => { + const data = formContext.getDataByPath(path); + const preferences = await getPreference(preferencesKey) || { fields: {} }; + dispatchRows({ type: 'SET_ALL', data: data || [], collapsedState: preferences?.fields?.[path]?.collapsed }); + }; + + initializeRowState(); + }, [formContext, path, getPreference, preferencesKey]); + + useEffect(() => { + setValue(rows?.length || 0, true); + + if (rows?.length === 0) { + setDisableFormData(false); + } else { + setDisableFormData(true); + } + }, [rows, setValue]); + + const hasMaxRows = maxRows && rows?.length >= maxRows; + + const classes = [ + 'field-type', + baseClass, + className, + ].filter(Boolean).join(' '); + + if (!rows) return null; + + return ( + +
+
+ +
+
+
+

{label}

+
    +
  • + +
  • +
  • + +
  • +
+
+ +
+ + {(provided) => ( +
+ {rows.length > 0 && rows.map((row, i) => { + const rowNumber = i + 1; + + return ( + + {(providedDrag) => ( +
+ setCollapse(row.id, collapsed)} + className={`${baseClass}__row`} + key={row.id} + dragHandleProps={providedDrag.dragHandleProps} + header={`${labels.singular} ${rowNumber >= 10 ? rowNumber : `0${rowNumber}`}`} + actions={!readOnly ? ( + + ) : undefined} + > + + ({ + ...field, + path: `${path}.${i}${fieldAffectsData(field) ? `.${field.name}` : ''}`, + }))} + /> + + +
+ )} +
+ ); + })} + {(rows.length < minRows || (required && rows.length === 0)) && ( + + This field requires at least + {' '} + {minRows + ? `${minRows} ${labels.plural}` + : `1 ${labels.singular}`} + + )} + {(rows.length === 0 && readOnly) && ( + + This field has no + {' '} + {labels.plural} + . + + )} + {provided.placeholder} +
+ )} +
+ {(!readOnly && !hasMaxRows) && ( +
+ +
+ )} +
+
+ ); +}; + +export default withCondition(ArrayFieldType); diff --git a/src/admin/components/forms/field-types/Blocks/Blocks.tsx b/src/admin/components/forms/field-types/Blocks/Blocks.tsx deleted file mode 100644 index dda9e502cc..0000000000 --- a/src/admin/components/forms/field-types/Blocks/Blocks.tsx +++ /dev/null @@ -1,417 +0,0 @@ -import React, { useCallback, useEffect, useReducer, useState } from 'react'; -import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd'; - -import { useAuth } from '../../../utilities/Auth'; -import { usePreferences } from '../../../utilities/Preferences'; -import { useLocale } from '../../../utilities/Locale'; -import withCondition from '../../withCondition'; -import Button from '../../../elements/Button'; -import reducer, { Row } from '../rowReducer'; -import { useDocumentInfo } from '../../../utilities/DocumentInfo'; -import { useForm } from '../../Form/context'; -import buildStateFromSchema from '../../Form/buildStateFromSchema'; -import Error from '../../Error'; -import useField from '../../useField'; -import Popup from '../../../elements/Popup'; -import BlockSelector from './BlockSelector'; -import { blocks as blocksValidator } from '../../../../../fields/validations'; -import Banner from '../../../elements/Banner'; -import FieldDescription from '../../FieldDescription'; -import { Props } from './types'; -import { useOperation } from '../../../utilities/OperationProvider'; -import { Collapsible } from '../../../elements/Collapsible'; -import { ArrayAction } from '../../../elements/ArrayAction'; -import RenderFields from '../../RenderFields'; -import { fieldAffectsData } from '../../../../../fields/config/types'; -import SectionTitle from './SectionTitle'; -import Pill from '../../../elements/Pill'; -import { scrollToID } from '../../../../utilities/scrollToID'; -import HiddenInput from '../HiddenInput'; - -import './index.scss'; - -const baseClass = 'blocks-field'; - -const labelDefaults = { - singular: 'Block', - plural: 'Blocks', -}; - -const Blocks: React.FC = (props) => { - const { - label, - name, - path: pathFromProps, - blocks, - labels = labelDefaults, - fieldTypes, - maxRows, - minRows, - required, - validate = blocksValidator, - permissions, - admin: { - readOnly, - description, - condition, - className, - }, - } = props; - - const path = pathFromProps || name; - - const { preferencesKey } = useDocumentInfo(); - const { getPreference } = usePreferences(); - const { setPreference } = usePreferences(); - const [rows, dispatchRows] = useReducer(reducer, undefined); - const formContext = useForm(); - const { user } = useAuth(); - const { id } = useDocumentInfo(); - const locale = useLocale(); - const operation = useOperation(); - const { dispatchFields } = formContext; - - const memoizedValidate = useCallback((value, options) => { - return validate(value, { ...options, minRows, maxRows, required }); - }, [maxRows, minRows, required, validate]); - - const [disableFormData, setDisableFormData] = useState(false); - const [selectorIndexOpen, setSelectorIndexOpen] = useState(); - - const { - showError, - errorMessage, - value, - setValue, - } = useField({ - path, - validate: memoizedValidate, - disableFormData, - condition, - }); - - const onAddPopupToggle = useCallback((open) => { - if (!open) { - setSelectorIndexOpen(undefined); - } - }, []); - - const addRow = useCallback(async (rowIndex: number, blockType: string) => { - const block = blocks.find((potentialBlock) => potentialBlock.slug === blockType); - const subFieldState = await buildStateFromSchema({ fieldSchema: block.fields, operation, id, user, locale }); - dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path, blockType }); - dispatchRows({ type: 'ADD', rowIndex, blockType }); - setValue(value as number + 1); - - setTimeout(() => { - scrollToID(`${path}-row-${rowIndex + 1}`); - }, 0); - }, [path, setValue, value, blocks, dispatchFields, operation, id, user, locale]); - - const duplicateRow = useCallback(async (rowIndex: number, blockType: string) => { - dispatchFields({ type: 'DUPLICATE_ROW', rowIndex, path }); - dispatchRows({ type: 'ADD', rowIndex, blockType }); - setValue(value as number + 1); - - setTimeout(() => { - scrollToID(`${path}-row-${rowIndex + 1}`); - }, 0); - }, [dispatchRows, dispatchFields, path, setValue, value]); - - const removeRow = useCallback((rowIndex: number) => { - dispatchRows({ type: 'REMOVE', rowIndex }); - dispatchFields({ type: 'REMOVE_ROW', rowIndex, path }); - setValue(value as number - 1); - }, [path, setValue, value, dispatchFields]); - - const moveRow = useCallback((moveFromIndex: number, moveToIndex: number) => { - dispatchRows({ type: 'MOVE', moveFromIndex, moveToIndex }); - dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path }); - }, [dispatchRows, dispatchFields, path]); - - const onDragEnd = useCallback((result) => { - if (!result.destination) return; - const sourceIndex = result.source.index; - const destinationIndex = result.destination.index; - moveRow(sourceIndex, destinationIndex); - }, [moveRow]); - - const setCollapse = useCallback(async (rowID: string, collapsed: boolean) => { - dispatchRows({ type: 'SET_COLLAPSE', id: rowID, collapsed }); - - if (preferencesKey) { - const preferencesToSet = await getPreference(preferencesKey) || { fields: {} }; - let newCollapsedState = preferencesToSet?.fields?.[path]?.collapsed - .filter((filterID) => (rows.find((row) => row.id === filterID))) - || []; - - if (!collapsed) { - newCollapsedState = newCollapsedState.filter((existingID) => existingID !== rowID); - } else { - newCollapsedState.push(rowID); - } - - setPreference(preferencesKey, { - ...preferencesToSet, - fields: { - ...preferencesToSet?.fields || {}, - [path]: { - ...preferencesToSet?.fields?.[path], - collapsed: newCollapsedState, - }, - }, - }); - } - }, [preferencesKey, getPreference, path, setPreference, rows]); - - const toggleCollapseAll = useCallback(async (collapse: boolean) => { - dispatchRows({ type: 'SET_ALL_COLLAPSED', collapse }); - - if (preferencesKey) { - const preferencesToSet = await getPreference(preferencesKey) || { fields: {} }; - - setPreference(preferencesKey, { - ...preferencesToSet, - fields: { - ...preferencesToSet?.fields || {}, - [path]: { - ...preferencesToSet?.fields?.[path], - collapsed: collapse ? rows.map(({ id: rowID }) => rowID) : [], - }, - }, - }); - } - }, [getPreference, path, preferencesKey, rows, setPreference]); - - // Set row count on mount and when form context is reset - useEffect(() => { - const initializeRowState = async () => { - const data = formContext.getDataByPath(path); - const preferences = await getPreference(preferencesKey) || { fields: {} }; - dispatchRows({ type: 'SET_ALL', data: data || [], collapsedState: preferences?.fields?.[path]?.collapsed }); - }; - - initializeRowState(); - }, [formContext, path, getPreference, preferencesKey]); - - useEffect(() => { - setValue(rows?.length || 0, true); - - if (rows?.length === 0) { - setDisableFormData(false); - } else { - setDisableFormData(true); - } - }, [rows, setValue]); - - const hasMaxRows = maxRows && rows?.length >= maxRows; - - const classes = [ - 'field-type', - baseClass, - className, - ].filter(Boolean).join(' '); - - if (!rows) return null; - - return ( - -
-
- -
-
-
-

{label}

-
    -
  • - -
  • -
  • - -
  • -
-
- -
- - - {(provided) => ( -
- {rows.length > 0 && rows.map((row, i) => { - const { blockType } = row; - const blockToRender = blocks.find((block) => block.slug === blockType); - - const rowNumber = i + 1; - - if (blockToRender) { - return ( - - {(providedDrag) => ( -
- setCollapse(row.id, collapsed)} - className={`${baseClass}__row`} - key={row.id} - dragHandleProps={providedDrag.dragHandleProps} - header={( -
- - {rowNumber >= 10 ? rowNumber : `0${rowNumber}`} - - - {blockToRender.labels.singular} - - -
- )} - actions={!readOnly ? ( - - ( - - )} - /> - duplicateRow(i, blockType)} - addRow={() => setSelectorIndexOpen(i)} - moveRow={moveRow} - removeRow={removeRow} - index={i} - /> - - ) : undefined} - > - - ({ - ...field, - path: `${path}.${i}${fieldAffectsData(field) ? `.${field.name}` : ''}`, - }))} - /> - -
-
- )} -
- ); - } - - return null; - })} - {(rows.length < minRows || (required && rows.length === 0)) && ( - - This field requires at least - {' '} - {`${minRows || 1} ${minRows === 1 || typeof minRows === 'undefined' ? labels.singular : labels.plural}`} - - )} - {(rows.length === 0 && readOnly) && ( - - This field has no - {' '} - {labels.plural} - . - - )} - {provided.placeholder} -
- )} -
- - {(!readOnly && !hasMaxRows) && ( -
- - {`Add ${labels.singular}`} - - )} - render={({ close }) => ( - - )} - /> -
- )} -
-
- ); -}; - -export default withCondition(Blocks); diff --git a/src/admin/components/forms/field-types/Blocks/index.tsx b/src/admin/components/forms/field-types/Blocks/index.tsx index 97c6d1c8ba..ea09fb458a 100644 --- a/src/admin/components/forms/field-types/Blocks/index.tsx +++ b/src/admin/components/forms/field-types/Blocks/index.tsx @@ -1,13 +1,417 @@ -import React, { Suspense, lazy } from 'react'; -import Loading from '../../../elements/Loading'; +import React, { useCallback, useEffect, useReducer, useState } from 'react'; +import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd'; + +import { useAuth } from '../../../utilities/Auth'; +import { usePreferences } from '../../../utilities/Preferences'; +import { useLocale } from '../../../utilities/Locale'; +import withCondition from '../../withCondition'; +import Button from '../../../elements/Button'; +import reducer, { Row } from '../rowReducer'; +import { useDocumentInfo } from '../../../utilities/DocumentInfo'; +import { useForm } from '../../Form/context'; +import buildStateFromSchema from '../../Form/buildStateFromSchema'; +import Error from '../../Error'; +import useField from '../../useField'; +import Popup from '../../../elements/Popup'; +import BlockSelector from './BlockSelector'; +import { blocks as blocksValidator } from '../../../../../fields/validations'; +import Banner from '../../../elements/Banner'; +import FieldDescription from '../../FieldDescription'; import { Props } from './types'; +import { useOperation } from '../../../utilities/OperationProvider'; +import { Collapsible } from '../../../elements/Collapsible'; +import { ArrayAction } from '../../../elements/ArrayAction'; +import RenderFields from '../../RenderFields'; +import { fieldAffectsData } from '../../../../../fields/config/types'; +import SectionTitle from './SectionTitle'; +import Pill from '../../../elements/Pill'; +import { scrollToID } from '../../../../utilities/scrollToID'; +import HiddenInput from '../HiddenInput'; -const Blocks = lazy(() => import('./Blocks')); +import './index.scss'; -const BlocksField: React.FC = (props) => ( - }> - - -); +const baseClass = 'blocks-field'; -export default BlocksField; +const labelDefaults = { + singular: 'Block', + plural: 'Blocks', +}; + +const Index: React.FC = (props) => { + const { + label, + name, + path: pathFromProps, + blocks, + labels = labelDefaults, + fieldTypes, + maxRows, + minRows, + required, + validate = blocksValidator, + permissions, + admin: { + readOnly, + description, + condition, + className, + }, + } = props; + + const path = pathFromProps || name; + + const { preferencesKey } = useDocumentInfo(); + const { getPreference } = usePreferences(); + const { setPreference } = usePreferences(); + const [rows, dispatchRows] = useReducer(reducer, undefined); + const formContext = useForm(); + const { user } = useAuth(); + const { id } = useDocumentInfo(); + const locale = useLocale(); + const operation = useOperation(); + const { dispatchFields } = formContext; + + const memoizedValidate = useCallback((value, options) => { + return validate(value, { ...options, minRows, maxRows, required }); + }, [maxRows, minRows, required, validate]); + + const [disableFormData, setDisableFormData] = useState(false); + const [selectorIndexOpen, setSelectorIndexOpen] = useState(); + + const { + showError, + errorMessage, + value, + setValue, + } = useField({ + path, + validate: memoizedValidate, + disableFormData, + condition, + }); + + const onAddPopupToggle = useCallback((open) => { + if (!open) { + setSelectorIndexOpen(undefined); + } + }, []); + + const addRow = useCallback(async (rowIndex: number, blockType: string) => { + const block = blocks.find((potentialBlock) => potentialBlock.slug === blockType); + const subFieldState = await buildStateFromSchema({ fieldSchema: block.fields, operation, id, user, locale }); + dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path, blockType }); + dispatchRows({ type: 'ADD', rowIndex, blockType }); + setValue(value as number + 1); + + setTimeout(() => { + scrollToID(`${path}-row-${rowIndex + 1}`); + }, 0); + }, [path, setValue, value, blocks, dispatchFields, operation, id, user, locale]); + + const duplicateRow = useCallback(async (rowIndex: number, blockType: string) => { + dispatchFields({ type: 'DUPLICATE_ROW', rowIndex, path }); + dispatchRows({ type: 'ADD', rowIndex, blockType }); + setValue(value as number + 1); + + setTimeout(() => { + scrollToID(`${path}-row-${rowIndex + 1}`); + }, 0); + }, [dispatchRows, dispatchFields, path, setValue, value]); + + const removeRow = useCallback((rowIndex: number) => { + dispatchRows({ type: 'REMOVE', rowIndex }); + dispatchFields({ type: 'REMOVE_ROW', rowIndex, path }); + setValue(value as number - 1); + }, [path, setValue, value, dispatchFields]); + + const moveRow = useCallback((moveFromIndex: number, moveToIndex: number) => { + dispatchRows({ type: 'MOVE', moveFromIndex, moveToIndex }); + dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path }); + }, [dispatchRows, dispatchFields, path]); + + const onDragEnd = useCallback((result) => { + if (!result.destination) return; + const sourceIndex = result.source.index; + const destinationIndex = result.destination.index; + moveRow(sourceIndex, destinationIndex); + }, [moveRow]); + + const setCollapse = useCallback(async (rowID: string, collapsed: boolean) => { + dispatchRows({ type: 'SET_COLLAPSE', id: rowID, collapsed }); + + if (preferencesKey) { + const preferencesToSet = await getPreference(preferencesKey) || { fields: {} }; + let newCollapsedState = preferencesToSet?.fields?.[path]?.collapsed + .filter((filterID) => (rows.find((row) => row.id === filterID))) + || []; + + if (!collapsed) { + newCollapsedState = newCollapsedState.filter((existingID) => existingID !== rowID); + } else { + newCollapsedState.push(rowID); + } + + setPreference(preferencesKey, { + ...preferencesToSet, + fields: { + ...preferencesToSet?.fields || {}, + [path]: { + ...preferencesToSet?.fields?.[path], + collapsed: newCollapsedState, + }, + }, + }); + } + }, [preferencesKey, getPreference, path, setPreference, rows]); + + const toggleCollapseAll = useCallback(async (collapse: boolean) => { + dispatchRows({ type: 'SET_ALL_COLLAPSED', collapse }); + + if (preferencesKey) { + const preferencesToSet = await getPreference(preferencesKey) || { fields: {} }; + + setPreference(preferencesKey, { + ...preferencesToSet, + fields: { + ...preferencesToSet?.fields || {}, + [path]: { + ...preferencesToSet?.fields?.[path], + collapsed: collapse ? rows.map(({ id: rowID }) => rowID) : [], + }, + }, + }); + } + }, [getPreference, path, preferencesKey, rows, setPreference]); + + // Set row count on mount and when form context is reset + useEffect(() => { + const initializeRowState = async () => { + const data = formContext.getDataByPath(path); + const preferences = await getPreference(preferencesKey) || { fields: {} }; + dispatchRows({ type: 'SET_ALL', data: data || [], collapsedState: preferences?.fields?.[path]?.collapsed }); + }; + + initializeRowState(); + }, [formContext, path, getPreference, preferencesKey]); + + useEffect(() => { + setValue(rows?.length || 0, true); + + if (rows?.length === 0) { + setDisableFormData(false); + } else { + setDisableFormData(true); + } + }, [rows, setValue]); + + const hasMaxRows = maxRows && rows?.length >= maxRows; + + const classes = [ + 'field-type', + baseClass, + className, + ].filter(Boolean).join(' '); + + if (!rows) return null; + + return ( + +
+
+ +
+
+
+

{label}

+
    +
  • + +
  • +
  • + +
  • +
+
+ +
+ + + {(provided) => ( +
+ {rows.length > 0 && rows.map((row, i) => { + const { blockType } = row; + const blockToRender = blocks.find((block) => block.slug === blockType); + + const rowNumber = i + 1; + + if (blockToRender) { + return ( + + {(providedDrag) => ( +
+ setCollapse(row.id, collapsed)} + className={`${baseClass}__row`} + key={row.id} + dragHandleProps={providedDrag.dragHandleProps} + header={( +
+ + {rowNumber >= 10 ? rowNumber : `0${rowNumber}`} + + + {blockToRender.labels.singular} + + +
+ )} + actions={!readOnly ? ( + + ( + + )} + /> + duplicateRow(i, blockType)} + addRow={() => setSelectorIndexOpen(i)} + moveRow={moveRow} + removeRow={removeRow} + index={i} + /> + + ) : undefined} + > + + ({ + ...field, + path: `${path}.${i}${fieldAffectsData(field) ? `.${field.name}` : ''}`, + }))} + /> + +
+
+ )} +
+ ); + } + + return null; + })} + {(rows.length < minRows || (required && rows.length === 0)) && ( + + This field requires at least + {' '} + {`${minRows || 1} ${minRows === 1 || typeof minRows === 'undefined' ? labels.singular : labels.plural}`} + + )} + {(rows.length === 0 && readOnly) && ( + + This field has no + {' '} + {labels.plural} + . + + )} + {provided.placeholder} +
+ )} +
+ + {(!readOnly && !hasMaxRows) && ( +
+ + {`Add ${labels.singular}`} + + )} + render={({ close }) => ( + + )} + /> +
+ )} +
+
+ ); +}; + +export default withCondition(Index); From c508ac6dee58eef88eec0efc8026f8e95d722ffd Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Wed, 24 Aug 2022 07:54:09 -0400 Subject: [PATCH 056/130] chore: exports PayloadHandler interface --- src/config/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/types.ts b/src/config/types.ts index 032bdccf92..f69ac760d6 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -79,7 +79,7 @@ export type AccessResult = boolean | Where; */ export type Access = (args?: any) => AccessResult | Promise; -interface PayloadHandler {( +export interface PayloadHandler {( req: PayloadRequest, res: Response, next: NextFunction, From a2fa99d06fe29c06904cde47c162aa48ff2eafa8 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 17 Aug 2022 20:12:43 -0400 Subject: [PATCH 057/130] ci: run actions on PRs --- .github/workflows/tests.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d5434454ff..a47f93d93e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,8 +1,10 @@ name: build on: - workflow_dispatch: + pull_request: + types: [opened, reopened, edited, synchronize] push: + branches: ['master'] jobs: build_yarn: From cf2eb3e482434de0e4bce257b23f26dcbf0bbcb7 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Fri, 26 Aug 2022 11:11:21 -0400 Subject: [PATCH 058/130] chore: fix dev hooks (#1006) --- test/hooks/collections/Hook/index.ts | 8 ++++++-- test/hooks/collections/Transform/index.ts | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/test/hooks/collections/Hook/index.ts b/test/hooks/collections/Hook/index.ts index 54c47930c6..2d71dfd0ea 100644 --- a/test/hooks/collections/Hook/index.ts +++ b/test/hooks/collections/Hook/index.ts @@ -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)], diff --git a/test/hooks/collections/Transform/index.ts b/test/hooks/collections/Transform/index.ts index 0b7cabe0ea..6c92d51398 100644 --- a/test/hooks/collections/Transform/index.ts +++ b/test/hooks/collections/Transform/index.ts @@ -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', From 6bc6e7bb616bd9f28f2464d3e55e7a1d19a8e7f8 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Mon, 29 Aug 2022 14:51:12 -0400 Subject: [PATCH 059/130] fix: require properties in blocks and arrays fields (#1020) * fix: make blocks required in block fields * fix: make fields required in array fields --- src/fields/config/sanitize.ts | 4 ++-- src/fields/config/schema.ts | 4 ++-- src/fields/config/types.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/fields/config/sanitize.ts b/src/fields/config/sanitize.ts index 277e173bbf..6cc387c45d 100644 --- a/src/fields/config/sanitize.ts +++ b/src/fields/config/sanitize.ts @@ -37,11 +37,11 @@ const sanitizeFields = (fields: Field[], validRelationships: string[]): Field[] }); } - if (field.type === 'blocks') { + if (field.type === 'blocks' && field.blocks) { field.blocks = field.blocks.map((block) => ({ ...block, fields: block.fields.concat(baseBlockFields) })); } - if (field.type === 'array') { + if (field.type === 'array' && field.fields) { field.fields.push(baseIDField); } diff --git a/src/fields/config/schema.ts b/src/fields/config/schema.ts index 560aaa091f..255814587a 100644 --- a/src/fields/config/schema.ts +++ b/src/fields/config/schema.ts @@ -217,7 +217,7 @@ export const array = baseField.keys({ name: joi.string().required(), minRows: joi.number(), maxRows: joi.number(), - fields: joi.array().items(joi.link('#field')), + fields: joi.array().items(joi.link('#field')).required(), labels: joi.object({ singular: joi.string(), plural: joi.string(), @@ -298,7 +298,7 @@ export const blocks = baseField.keys({ }), fields: joi.array().items(joi.link('#field')), }), - ), + ).required(), defaultValue: joi.alternatives().try( joi.array().items(joi.object()), joi.func(), diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index 9fc89fd64d..d5a64d2267 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -301,7 +301,7 @@ export type ArrayField = FieldBase & { minRows?: number; maxRows?: number; labels?: Labels; - fields?: Field[]; + fields: Field[]; } export type RadioField = FieldBase & { @@ -324,7 +324,7 @@ export type BlockField = FieldBase & { type: 'blocks'; minRows?: number; maxRows?: number; - blocks?: Block[]; + blocks: Block[]; defaultValue?: unknown labels?: Labels } From 50b0303ab39f0d0500c5e4116df95f02d1d7fff3 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Mon, 29 Aug 2022 14:52:17 -0400 Subject: [PATCH 060/130] fix: incorrect field paths when nesting unnamed fields (#1011) * chore: reproduce issue #976 * fix: incorrect field paths when nesting non-named fields --- .../forms/field-types/Collapsible/index.tsx | 4 +-- .../forms/field-types/Row/index.tsx | 4 +-- .../forms/field-types/Tabs/index.tsx | 4 +-- .../forms/field-types/getFieldPath.ts | 7 ++++ test/fields/collections/Blocks/index.ts | 36 +++++++++++++++++++ 5 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 src/admin/components/forms/field-types/getFieldPath.ts diff --git a/src/admin/components/forms/field-types/Collapsible/index.tsx b/src/admin/components/forms/field-types/Collapsible/index.tsx index 49d874b9fa..a1103f93ad 100644 --- a/src/admin/components/forms/field-types/Collapsible/index.tsx +++ b/src/admin/components/forms/field-types/Collapsible/index.tsx @@ -2,13 +2,13 @@ import React, { useCallback, useEffect, useState } from 'react'; import RenderFields from '../../RenderFields'; import withCondition from '../../withCondition'; import { Props } from './types'; -import { fieldAffectsData } from '../../../../../fields/config/types'; import { Collapsible } from '../../../elements/Collapsible'; import toKebabCase from '../../../../../utilities/toKebabCase'; import { usePreferences } from '../../../utilities/Preferences'; import { DocumentPreferences } from '../../../../../preferences/types'; import { useDocumentInfo } from '../../../utilities/DocumentInfo'; import FieldDescription from '../../FieldDescription'; +import { getFieldPath } from '../getFieldPath'; import './index.scss'; @@ -78,7 +78,7 @@ const CollapsibleField: React.FC = (props) => { fieldTypes={fieldTypes} fieldSchema={fields.map((field) => ({ ...field, - path: `${path ? `${path}.` : ''}${fieldAffectsData(field) ? field.name : ''}`, + path: getFieldPath(path, field), }))} /> diff --git a/src/admin/components/forms/field-types/Row/index.tsx b/src/admin/components/forms/field-types/Row/index.tsx index 9a118958a1..c5878dd542 100644 --- a/src/admin/components/forms/field-types/Row/index.tsx +++ b/src/admin/components/forms/field-types/Row/index.tsx @@ -2,7 +2,7 @@ import React from 'react'; import RenderFields from '../../RenderFields'; import withCondition from '../../withCondition'; import { Props } from './types'; -import { fieldAffectsData } from '../../../../../fields/config/types'; +import { getFieldPath } from '../getFieldPath'; import './index.scss'; @@ -32,7 +32,7 @@ const Row: React.FC = (props) => { fieldTypes={fieldTypes} fieldSchema={fields.map((field) => ({ ...field, - path: `${path ? `${path}.` : ''}${fieldAffectsData(field) ? field.name : ''}`, + path: getFieldPath(path, field), }))} /> ); diff --git a/src/admin/components/forms/field-types/Tabs/index.tsx b/src/admin/components/forms/field-types/Tabs/index.tsx index 80dea69f2c..06e2945b38 100644 --- a/src/admin/components/forms/field-types/Tabs/index.tsx +++ b/src/admin/components/forms/field-types/Tabs/index.tsx @@ -2,11 +2,11 @@ import React, { useState } from 'react'; import RenderFields from '../../RenderFields'; import withCondition from '../../withCondition'; import { Props } from './types'; -import { fieldAffectsData } from '../../../../../fields/config/types'; import FieldDescription from '../../FieldDescription'; import toKebabCase from '../../../../../utilities/toKebabCase'; import { useCollapsible } from '../../../elements/Collapsible/provider'; import { TabsProvider } from './provider'; +import { getFieldPath } from '../getFieldPath'; import './index.scss'; @@ -75,7 +75,7 @@ const TabsField: React.FC = (props) => { fieldTypes={fieldTypes} fieldSchema={activeTab.fields.map((field) => ({ ...field, - path: `${path ? `${path}.` : ''}${fieldAffectsData(field) ? field.name : ''}`, + path: getFieldPath(path, field), }))} /> diff --git a/src/admin/components/forms/field-types/getFieldPath.ts b/src/admin/components/forms/field-types/getFieldPath.ts new file mode 100644 index 0000000000..5bebb261a9 --- /dev/null +++ b/src/admin/components/forms/field-types/getFieldPath.ts @@ -0,0 +1,7 @@ +import { Field, fieldAffectsData } from '../../../../fields/config/types'; + +export const getFieldPath = (path: string, field: Field): string => { + // prevents duplicate . on nesting non-named fields + const dot = path && path.slice(-1) === '.' ? '' : '.'; + return `${path ? `${path}${dot}` : ''}${fieldAffectsData(field) ? field.name : ''}`; +}; diff --git a/test/fields/collections/Blocks/index.ts b/test/fields/collections/Blocks/index.ts index 77c0fd3905..6bee37e487 100644 --- a/test/fields/collections/Blocks/index.ts +++ b/test/fields/collections/Blocks/index.ts @@ -67,6 +67,42 @@ 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', + }, + ], + }, + ], + }, + ], + }, + ], + }, ], }; From d727fc8e2467e3f438ea6b1d2031e0657bffd183 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Mon, 29 Aug 2022 14:57:06 -0400 Subject: [PATCH 061/130] feat: validate relationship and upload ids (#1004) * feat: validate relationship and upload ids * chore: update fields-relationship test data * fix: skip FE relationship and upload id validation --- src/collections/dataloader.ts | 22 +++++++++---- src/fields/validations.ts | 52 ++++++++++++++++++++++++++++++ src/utilities/getIDType.ts | 8 +++++ src/utilities/isValidID.ts | 9 ++++++ test/fields-relationship/config.ts | 2 +- test/relationships/int.spec.ts | 16 +++++++++ 6 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 src/utilities/getIDType.ts create mode 100644 src/utilities/isValidID.ts diff --git a/src/collections/dataloader.ts b/src/collections/dataloader.ts index bfae5748fd..b02e9bd7d4 100644 --- a/src/collections/dataloader.ts +++ b/src/collections/dataloader.ts @@ -1,6 +1,9 @@ import DataLoader, { BatchLoadFn } from 'dataloader'; import { PayloadRequest } from '../express/types'; import { TypeWithID } from '../globals/config/types'; +import { isValidID } from '../utilities/isValidID'; +import { getIDType } from '../utilities/getIDType'; +import { fieldAffectsData } from '../fields/config/types'; // Payload uses `dataloader` to solve the classic GraphQL N+1 problem. @@ -49,13 +52,18 @@ const batchAndLoadDocs = (req: PayloadRequest): BatchLoadFn const batchKey = JSON.stringify(batchKeyArray); - return { - ...batches, - [batchKey]: [ - ...batches[batchKey] || [], - id, - ], - }; + const idField = payload.collections?.[collection].config.fields.find((field) => fieldAffectsData(field) && field.name === 'id'); + + if (isValidID(id, getIDType(idField))) { + return { + ...batches, + [batchKey]: [ + ...batches[batchKey] || [], + id, + ], + }; + } + return batches; }, {}); // Run find requests in parallel diff --git a/src/fields/validations.ts b/src/fields/validations.ts index 1220062bc6..13620ddc8e 100644 --- a/src/fields/validations.ts +++ b/src/fields/validations.ts @@ -17,9 +17,12 @@ import { TextField, UploadField, Validate, + fieldAffectsData, } from './config/types'; import { TypeWithID } from '../collections/config/types'; import canUseDOM from '../utilities/canUseDOM'; +import { isValidID } from '../utilities/isValidID'; +import { getIDType } from '../utilities/getIDType'; const defaultMessage = 'This field is required.'; @@ -232,6 +235,15 @@ export const upload: Validate = (value: string, o return defaultMessage; } + if (!canUseDOM) { + const idField = options.payload.collections[options.relationTo].config.fields.find((field) => fieldAffectsData(field) && field.name === 'id'); + const type = getIDType(idField); + + if (!isValidID(value, type)) { + return 'This field is not a valid upload ID'; + } + } + return validateFilterOptions(value, options); }; @@ -240,6 +252,46 @@ export const relationship: Validate = async return defaultMessage; } + if (!canUseDOM && typeof value !== 'undefined') { + const values = Array.isArray(value) ? value : [value]; + + const invalidRelationships = values.filter((val) => { + let collection: string; + let requestedID: string | number; + + if (typeof options.relationTo === 'string') { + collection = options.relationTo; + + // custom id + if (typeof val === 'string' || typeof val === 'number') { + requestedID = val; + } + } + + if (Array.isArray(options.relationTo) && typeof val === 'object' && val?.relationTo) { + collection = val.relationTo; + requestedID = val.value; + } + + const idField = options.payload.collections[collection].config.fields.find((field) => fieldAffectsData(field) && field.name === 'id'); + let type; + if (idField) { + type = idField.type === 'number' ? 'number' : 'text'; + } else { + type = 'ObjectID'; + } + + return !isValidID(requestedID, type); + }); + + if (invalidRelationships.length > 0) { + return `This field has the following invalid selections: ${ + invalidRelationships.map((err, invalid) => { + return `${err} ${JSON.stringify(invalid)}`; + }).join(', ')}` as string; + } + } + return validateFilterOptions(value, options); }; diff --git a/src/utilities/getIDType.ts b/src/utilities/getIDType.ts new file mode 100644 index 0000000000..ecae09c472 --- /dev/null +++ b/src/utilities/getIDType.ts @@ -0,0 +1,8 @@ +import { Field } from '../fields/config/types'; + +export const getIDType = (idField: Field | null): 'number' | 'text' | 'ObjectID' => { + if (idField) { + return idField.type === 'number' ? 'number' : 'text'; + } + return 'ObjectID'; +}; diff --git a/src/utilities/isValidID.ts b/src/utilities/isValidID.ts new file mode 100644 index 0000000000..036995730b --- /dev/null +++ b/src/utilities/isValidID.ts @@ -0,0 +1,9 @@ +import ObjectID from 'bson-objectid'; + +export const isValidID = (value: string | number, type: 'text' | 'number' | 'ObjectID'): boolean => { + if (type === 'ObjectID') { + return ObjectID.isValid(String(value)); + } + return (type === 'text' && typeof value === 'string') + || (type === 'number' && typeof value === 'number' && !Number.isNaN(value)); +}; diff --git a/test/fields-relationship/config.ts b/test/fields-relationship/config.ts index d197f893c2..1a12ef7ff1 100644 --- a/test/fields-relationship/config.ts +++ b/test/fields-relationship/config.ts @@ -128,7 +128,7 @@ export default buildConfig({ }); const relationOneIDs = []; - await mapAsync([...Array(5)], async () => { + await mapAsync([...Array(11)], async () => { const doc = await payload.create({ collection: relationOneSlug, data: { diff --git a/test/relationships/int.spec.ts b/test/relationships/int.spec.ts index e278237bcc..8757050f87 100644 --- a/test/relationships/int.spec.ts +++ b/test/relationships/int.spec.ts @@ -168,6 +168,22 @@ describe('Relationships', () => { const { doc } = await client.findByID({ 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'); + }); }); describe('depth', () => { From 3736755a12cf5bbaaa916a5c0363026318a60823 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Mon, 29 Aug 2022 14:57:30 -0400 Subject: [PATCH 062/130] feat: types custom components to allow any props (#1013) --- .../elements/ViewDescription/types.ts | 2 +- .../components/forms/field-types/index.tsx | 46 +++++++++---------- .../utilities/RenderCustomComponent/types.ts | 4 +- src/collections/config/types.ts | 4 +- src/config/types.ts | 24 +++++----- src/fields/config/types.ts | 14 +++--- src/globals/config/types.ts | 2 +- 7 files changed, 48 insertions(+), 48 deletions(-) diff --git a/src/admin/components/elements/ViewDescription/types.ts b/src/admin/components/elements/ViewDescription/types.ts index 0c5404ab29..30db16027f 100644 --- a/src/admin/components/elements/ViewDescription/types.ts +++ b/src/admin/components/elements/ViewDescription/types.ts @@ -2,7 +2,7 @@ import React from 'react'; export type DescriptionFunction = () => string -export type DescriptionComponent = React.ComponentType +export type DescriptionComponent = React.ComponentType type Description = string | DescriptionFunction | DescriptionComponent diff --git a/src/admin/components/forms/field-types/index.tsx b/src/admin/components/forms/field-types/index.tsx index 964d613fa4..2ff0ca6eb7 100644 --- a/src/admin/components/forms/field-types/index.tsx +++ b/src/admin/components/forms/field-types/index.tsx @@ -25,29 +25,29 @@ import upload from './Upload'; import ui from './UI'; export type FieldTypes = { - code: React.ComponentType - email: React.ComponentType - hidden: React.ComponentType - text: React.ComponentType - date: React.ComponentType - password: React.ComponentType - confirmPassword: React.ComponentType - relationship: React.ComponentType - textarea: React.ComponentType - select: React.ComponentType - number: React.ComponentType - point: React.ComponentType - checkbox: React.ComponentType - richText: React.ComponentType - radio: React.ComponentType - blocks: React.ComponentType - group: React.ComponentType - array: React.ComponentType - row: React.ComponentType - collapsible: React.ComponentType - tabs: React.ComponentType - upload: React.ComponentType - ui: React.ComponentType + code: React.ComponentType + email: React.ComponentType + hidden: React.ComponentType + text: React.ComponentType + date: React.ComponentType + password: React.ComponentType + confirmPassword: React.ComponentType + relationship: React.ComponentType + textarea: React.ComponentType + select: React.ComponentType + number: React.ComponentType + point: React.ComponentType + checkbox: React.ComponentType + richText: React.ComponentType + radio: React.ComponentType + blocks: React.ComponentType + group: React.ComponentType + array: React.ComponentType + row: React.ComponentType + collapsible: React.ComponentType + tabs: React.ComponentType + upload: React.ComponentType + ui: React.ComponentType } const fieldTypes: FieldTypes = { diff --git a/src/admin/components/utilities/RenderCustomComponent/types.ts b/src/admin/components/utilities/RenderCustomComponent/types.ts index 549756526f..10499393cb 100644 --- a/src/admin/components/utilities/RenderCustomComponent/types.ts +++ b/src/admin/components/utilities/RenderCustomComponent/types.ts @@ -1,7 +1,7 @@ import React from 'react'; export type Props = { - CustomComponent: React.ComponentType - DefaultComponent: React.ComponentType + CustomComponent: React.ComponentType + DefaultComponent: React.ComponentType componentProps?: Record } diff --git a/src/collections/config/types.ts b/src/collections/config/types.ts index cd2017c21e..1e7396a492 100644 --- a/src/collections/config/types.ts +++ b/src/collections/config/types.ts @@ -165,8 +165,8 @@ export type CollectionAdminOptions = { */ components?: { views?: { - Edit?: React.ComponentType - List?: React.ComponentType + Edit?: React.ComponentType + List?: React.ComponentType } }; pagination?: { diff --git a/src/config/types.ts b/src/config/types.ts index f69ac760d6..1a696c4a31 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -122,20 +122,20 @@ export type Config = { components?: { routes?: AdminRoute[] providers?: React.ComponentType<{ children: React.ReactNode }>[] - beforeDashboard?: React.ComponentType[] - afterDashboard?: React.ComponentType[] - beforeLogin?: React.ComponentType[] - afterLogin?: React.ComponentType[] - beforeNavLinks?: React.ComponentType[] - afterNavLinks?: React.ComponentType[] - Nav?: React.ComponentType + beforeDashboard?: React.ComponentType[] + afterDashboard?: React.ComponentType[] + beforeLogin?: React.ComponentType[] + afterLogin?: React.ComponentType[] + beforeNavLinks?: React.ComponentType[] + afterNavLinks?: React.ComponentType[] + Nav?: React.ComponentType graphics?: { - Icon?: React.ComponentType - Logo?: React.ComponentType + Icon?: React.ComponentType + Logo?: React.ComponentType } views?: { - Account?: React.ComponentType - Dashboard?: React.ComponentType + Account?: React.ComponentType + Dashboard?: React.ComponentType } } pagination?: { @@ -209,4 +209,4 @@ export type SanitizedConfig = Omit, 'collections' | 'global paths: { [key: string]: string }; } -export type EntityDescription = string | (() => string) | React.ComponentType +export type EntityDescription = string | (() => string) | React.ComponentType diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index d5a64d2267..824c50eb9b 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -51,9 +51,9 @@ type Admin = { condition?: Condition; description?: Description; components?: { - Filter?: React.ComponentType; - Cell?: React.ComponentType; - Field?: React.ComponentType; + Filter?: React.ComponentType; + Cell?: React.ComponentType; + Field?: React.ComponentType; } hidden?: boolean } @@ -264,15 +264,15 @@ type RichTextPlugin = (editor: Editor) => Editor; export type RichTextCustomElement = { name: string - Button: React.ComponentType - Element: React.ComponentType + Button: React.ComponentType + Element: React.ComponentType plugins?: RichTextPlugin[] } export type RichTextCustomLeaf = { name: string - Button: React.ComponentType - Leaf: React.ComponentType + Button: React.ComponentType + Leaf: React.ComponentType plugins?: RichTextPlugin[] } diff --git a/src/globals/config/types.ts b/src/globals/config/types.ts index b0fcaa47d9..50aba5405b 100644 --- a/src/globals/config/types.ts +++ b/src/globals/config/types.ts @@ -68,7 +68,7 @@ export type GlobalConfig = { hideAPIURL?: boolean; components?: { views?: { - Edit?: React.ComponentType + Edit?: React.ComponentType } } } From 0586d7aa7d0938df25492487aa073c2aa366e1e4 Mon Sep 17 00:00:00 2001 From: Hung Vu Date: Mon, 29 Aug 2022 12:39:46 -0700 Subject: [PATCH 063/130] fix: unpublish item will not crash the UI anymore (#1016) --- src/admin/components/elements/Status/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/admin/components/elements/Status/index.tsx b/src/admin/components/elements/Status/index.tsx index 1ea0ad18ff..1ea54765b3 100644 --- a/src/admin/components/elements/Status/index.tsx +++ b/src/admin/components/elements/Status/index.tsx @@ -55,7 +55,7 @@ const Status: React.FC = () => { if (collection) { url = `${serverURL}${api}/${collection.slug}/${id}?depth=0&locale=${locale}&fallback-locale=null`; - method = 'PATCH'; + method = 'patch'; } if (global) { url = `${serverURL}${api}/globals/${global.slug}?depth=0&locale=${locale}&fallback-locale=null`; From 6a3cfced9a6e0ef75b398ec663f908c725b10d1a Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Mon, 29 Aug 2022 17:03:45 -0400 Subject: [PATCH 064/130] fix: relationship cell loading (#1021) * fix: relationship cell lazy loading items in viewport * fix: collection list page param changing --- .../elements/WhereBuilder/index.tsx | 19 ++++++++++++------- .../Cell/field-types/Relationship/index.tsx | 2 +- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/admin/components/elements/WhereBuilder/index.tsx b/src/admin/components/elements/WhereBuilder/index.tsx index 584621695f..582957464c 100644 --- a/src/admin/components/elements/WhereBuilder/index.tsx +++ b/src/admin/components/elements/WhereBuilder/index.tsx @@ -72,13 +72,18 @@ const WhereBuilder: React.FC = (props) => { if (handleChange) handleChange(newWhereQuery as Where); if (modifySearchQuery) { - history.replace({ - search: queryString.stringify({ - ...currentParams, - page: 1, - where: newWhereQuery, - }, { addQueryPrefix: true }), - }); + const newParams = { + ...currentParams, + page: currentParams.page, + where: newWhereQuery, + }; + if (newParams.page) delete newParams.page; + const newSearchQuery = queryString.stringify(newParams); + if (newSearchQuery) { + history.replace({ + search: `?${newSearchQuery}&page=1`, + }); + } } }, 500, [conditions, modifySearchQuery, handleChange]); diff --git a/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.tsx b/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.tsx index b98c880417..ba88508c08 100644 --- a/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.tsx +++ b/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.tsx @@ -17,7 +17,7 @@ const RelationshipCell = (props) => { const { getRelationships, documents } = useListRelationships(); const [hasRequested, setHasRequested] = useState(false); - const isAboveViewport = entry?.boundingClientRect?.top > 0; + const isAboveViewport = entry?.boundingClientRect?.top < window.innerHeight; useEffect(() => { if (cellData && isAboveViewport && !hasRequested) { From 0cd8446735ba764238f007b592679f178054dbf8 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Mon, 29 Aug 2022 17:08:17 -0400 Subject: [PATCH 065/130] chore(release): v1.0.28 --- CHANGELOG.md | 18 ++++++++++++++++++ package.json | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37e0efefa8..e9f097db82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +## [1.0.28](https://github.com/payloadcms/payload/compare/v1.0.27...v1.0.28) (2022-08-29) + + +### Bug Fixes + +* incorrect field paths when nesting unnamed fields ([#1011](https://github.com/payloadcms/payload/issues/1011)) ([50b0303](https://github.com/payloadcms/payload/commit/50b0303ab39f0d0500c5e4116df95f02d1d7fff3)), closes [#976](https://github.com/payloadcms/payload/issues/976) +* relationship cell loading ([#1021](https://github.com/payloadcms/payload/issues/1021)) ([6a3cfce](https://github.com/payloadcms/payload/commit/6a3cfced9a6e0ef75b398ec663f908c725b10d1a)) +* remove lazy loading of array and blocks ([4900fa7](https://github.com/payloadcms/payload/commit/4900fa799ffbeb70e689622b269dc04a67978552)) +* require properties in blocks and arrays fields ([#1020](https://github.com/payloadcms/payload/issues/1020)) ([6bc6e7b](https://github.com/payloadcms/payload/commit/6bc6e7bb616bd9f28f2464d3e55e7a1d19a8e7f8)) +* unpublish item will not crash the UI anymore ([#1016](https://github.com/payloadcms/payload/issues/1016)) ([0586d7a](https://github.com/payloadcms/payload/commit/0586d7aa7d0938df25492487aa073c2aa366e1e4)) + + +### Features + +* export more fields config types and validation type ([#989](https://github.com/payloadcms/payload/issues/989)) ([25f5d68](https://github.com/payloadcms/payload/commit/25f5d68b74b081c060ddf6f0405c9211f5da6b54)) +* types custom components to allow any props ([#1013](https://github.com/payloadcms/payload/issues/1013)) ([3736755](https://github.com/payloadcms/payload/commit/3736755a12cf5bbaaa916a5c0363026318a60823)) +* validate relationship and upload ids ([#1004](https://github.com/payloadcms/payload/issues/1004)) ([d727fc8](https://github.com/payloadcms/payload/commit/d727fc8e2467e3f438ea6b1d2031e0657bffd183)) + ## [1.0.27](https://github.com/payloadcms/payload/compare/v1.0.26...v1.0.27) (2022-08-18) diff --git a/package.json b/package.json index 109a671b2d..9333e58405 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "1.0.27", + "version": "1.0.28", "description": "Node, React and MongoDB Headless CMS and Application Framework", "license": "MIT", "author": { From e88c7ca4b2c8dd50a4fec9a2dbeba9a629101f74 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 29 Aug 2022 15:13:14 -0700 Subject: [PATCH 066/130] chore: revises the logic while clearing wherebuilder queries --- .../elements/WhereBuilder/index.tsx | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/admin/components/elements/WhereBuilder/index.tsx b/src/admin/components/elements/WhereBuilder/index.tsx index 582957464c..0fd47cef8d 100644 --- a/src/admin/components/elements/WhereBuilder/index.tsx +++ b/src/admin/components/elements/WhereBuilder/index.tsx @@ -71,19 +71,17 @@ const WhereBuilder: React.FC = (props) => { if (handleChange) handleChange(newWhereQuery as Where); - if (modifySearchQuery) { - const newParams = { - ...currentParams, - page: currentParams.page, - where: newWhereQuery, - }; - if (newParams.page) delete newParams.page; - const newSearchQuery = queryString.stringify(newParams); - if (newSearchQuery) { - history.replace({ - search: `?${newSearchQuery}&page=1`, - }); - } + const hasExistingConditions = typeof currentParams?.where === 'object' && 'or' in currentParams.where; + const hasNewWhereConditions = conditions.length > 0; + + if (modifySearchQuery && ((hasExistingConditions && !hasNewWhereConditions) || hasNewWhereConditions)) { + history.replace({ + search: queryString.stringify({ + ...currentParams, + page: 1, + where: newWhereQuery, + }, { addQueryPrefix: true }), + }); } }, 500, [conditions, modifySearchQuery, handleChange]); From a73c391c2cecc3acf8dc3115b56c018f85d9bebf Mon Sep 17 00:00:00 2001 From: James Date: Mon, 29 Aug 2022 15:41:59 -0700 Subject: [PATCH 067/130] fix: #953 --- src/collections/buildSchema.ts | 1 + src/mongoose/buildSchema.ts | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/collections/buildSchema.ts b/src/collections/buildSchema.ts index 20ae38cb37..1de4479087 100644 --- a/src/collections/buildSchema.ts +++ b/src/collections/buildSchema.ts @@ -10,6 +10,7 @@ const buildCollectionSchema = (collection: SanitizedCollectionConfig, config: Sa config, collection.fields, { + draftsEnabled: Boolean(typeof collection?.versions === 'object' && collection.versions.drafts), options: { timestamps: collection.timestamps !== false, ...schemaOptions }, }, ); diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index c011b7d03a..d52407596c 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -10,6 +10,7 @@ export type BuildSchemaOptions = { options?: SchemaOptions allowIDField?: boolean disableUnique?: boolean + draftsEnabled?: boolean global?: boolean } @@ -19,11 +20,13 @@ const formatBaseSchema = (field: NonPresentationalField, buildSchemaOptions: Bui const schema: SchemaTypeOptions = { unique: (!buildSchemaOptions.disableUnique && field.unique) || false, required: false, - index: field.index || field.unique || false, + index: field.index || (!buildSchemaOptions.disableUnique && field.unique) || false, }; - if (field.unique && field.localized) { + + if ((schema.unique && (field.localized || buildSchemaOptions.draftsEnabled))) { schema.sparse = true; } + return schema; }; From 1a4ce65e6c5a253eedb72ee0c1adb406a2843ba9 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 29 Aug 2022 15:58:30 -0700 Subject: [PATCH 068/130] chore(release): v1.0.29 --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f097db82..da87ee8d51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [1.0.29](https://github.com/payloadcms/payload/compare/v1.0.28...v1.0.29) (2022-08-29) + + +### Bug Fixes + +* [#953](https://github.com/payloadcms/payload/issues/953) ([a73c391](https://github.com/payloadcms/payload/commit/a73c391c2cecc3acf8dc3115b56c018f85d9bebf)) + ## [1.0.28](https://github.com/payloadcms/payload/compare/v1.0.27...v1.0.28) (2022-08-29) diff --git a/package.json b/package.json index 9333e58405..5e375f1a28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "1.0.28", + "version": "1.0.29", "description": "Node, React and MongoDB Headless CMS and Application Framework", "license": "MIT", "author": { From 689fa008fb0b28fb92be4ca785a77f4c35ae16b2 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Mon, 29 Aug 2022 22:49:08 -0400 Subject: [PATCH 069/130] fix: upload field validation not required (#1025) --- src/fields/validations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fields/validations.ts b/src/fields/validations.ts index 13620ddc8e..06e1f65697 100644 --- a/src/fields/validations.ts +++ b/src/fields/validations.ts @@ -235,7 +235,7 @@ export const upload: Validate = (value: string, o return defaultMessage; } - if (!canUseDOM) { + if (!canUseDOM && typeof value !== 'undefined') { const idField = options.payload.collections[options.relationTo].config.fields.find((field) => fieldAffectsData(field) && field.name === 'id'); const type = getIDType(idField); From a9ef557ca409dc04f211651f48e717f7154b5f4e Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Mon, 29 Aug 2022 22:55:12 -0400 Subject: [PATCH 070/130] chore(release): v1.0.30 --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da87ee8d51..75703c4288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [1.0.30](https://github.com/payloadcms/payload/compare/v1.0.29...v1.0.30) (2022-08-30) + + +### Bug Fixes + +* upload field validation not required ([#1025](https://github.com/payloadcms/payload/issues/1025)) ([689fa00](https://github.com/payloadcms/payload/commit/689fa008fb0b28fb92be4ca785a77f4c35ae16b2)) + ## [1.0.29](https://github.com/payloadcms/payload/compare/v1.0.28...v1.0.29) (2022-08-29) diff --git a/package.json b/package.json index 5e375f1a28..e29ca2800a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "1.0.29", + "version": "1.0.30", "description": "Node, React and MongoDB Headless CMS and Application Framework", "license": "MIT", "author": { From 8df9ee7b2dfcb2f77f049d02788a5c60c45f8c12 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 30 Aug 2022 11:54:41 -0700 Subject: [PATCH 071/130] fix: #948 --- .../components/forms/RenderFields/index.tsx | 2 +- .../forms/field-types/Tabs/index.scss | 3 +- .../forms/field-types/Tabs/index.tsx | 42 +++++++++---------- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/admin/components/forms/RenderFields/index.tsx b/src/admin/components/forms/RenderFields/index.tsx index b849f4d67f..9c07df0c79 100644 --- a/src/admin/components/forms/RenderFields/index.tsx +++ b/src/admin/components/forms/RenderFields/index.tsx @@ -22,7 +22,7 @@ const RenderFields: React.FC = (props) => { forceRender, } = props; - const [hasRendered, setHasRendered] = useState(false); + const [hasRendered, setHasRendered] = useState(Boolean(forceRender)); const [intersectionRef, entry] = useIntersect(intersectionObserverOptions); const operation = useOperation(); diff --git a/src/admin/components/forms/field-types/Tabs/index.scss b/src/admin/components/forms/field-types/Tabs/index.scss index 36bcc45ce2..dc827c7d9b 100644 --- a/src/admin/components/forms/field-types/Tabs/index.scss +++ b/src/admin/components/forms/field-types/Tabs/index.scss @@ -20,6 +20,7 @@ } .tabs-field__tabs { + &:before, &:after { content: ' '; @@ -111,4 +112,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/admin/components/forms/field-types/Tabs/index.tsx b/src/admin/components/forms/field-types/Tabs/index.tsx index 06e2945b38..948e05e3ef 100644 --- a/src/admin/components/forms/field-types/Tabs/index.tsx +++ b/src/admin/components/forms/field-types/Tabs/index.tsx @@ -58,27 +58,27 @@ const TabsField: React.FC = (props) => {
{activeTab && ( -
- - ({ - ...field, - path: getFieldPath(path, field), - }))} - /> -
+
+ + ({ + ...field, + path: getFieldPath(path, field), + }))} + /> +
)}
From e31098eaa560fda118776c2dacee8403f7b6827e Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Tue, 30 Aug 2022 16:25:26 -0400 Subject: [PATCH 072/130] test: duplicate copies all locales --- test/localization/e2e.spec.ts | 36 +++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/localization/e2e.spec.ts b/test/localization/e2e.spec.ts index df362cb798..39642e8384 100644 --- a/test/localization/e2e.spec.ts +++ b/test/localization/e2e.spec.ts @@ -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({ + collection: slug, + data: { + title: englishTitle, + }, + }); + id = localizedPost.id; + await payload.update({ + 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>) { From 18ff5d29b0dcfb647b760d7053ac199de59a5c55 Mon Sep 17 00:00:00 2001 From: Elliot Lintz <45725915+Elliot67@users.noreply.github.com> Date: Tue, 30 Aug 2022 22:41:00 +0200 Subject: [PATCH 073/130] docs: fix graphql `disable` property explanation --- docs/graphql/overview.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/graphql/overview.mdx b/docs/graphql/overview.mdx index cbc31b8c19..9b626bab25 100644 --- a/docs/graphql/overview.mdx +++ b/docs/graphql/overview.mdx @@ -22,7 +22,7 @@ At the top of your Payload config you can define all the options to manage Graph | `queries` | Any custom Queries to be added in addition to what Payload provides. [More](/docs/graphql/extending) | | `maxComplexity` | A number used to set the maximum allowed complexity allowed by requests [More](/docs/graphql/overview#query-complexity-limits) | | `disablePlaygroundInProduction` | A boolean that if false will enable the graphQL playground, defaults to true. [More](/docs/graphql/overview#graphql-playground) | -| `disable` | A boolean that if false will disable the graphQL entirely, defaults to false. | +| `disable` | A boolean that if true will disable the graphQL entirely, defaults to false. | | `schemaOutputFile` | A string for the file path used by the generate schema command. Defaults to `graphql.schema` next to `payload.config.ts` [More](/docs/graphql/graphql-schema) | ## Collections From 51c7770b10c34a3e40520ca8d64beedc67693c5c Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Tue, 30 Aug 2022 17:23:30 -0400 Subject: [PATCH 074/130] feat: duplicate copies all locales --- .../elements/DuplicateDocument/index.scss | 22 +++ .../elements/DuplicateDocument/index.tsx | 146 +++++++++++++++--- .../elements/DuplicateDocument/types.ts | 6 +- .../views/collections/Edit/Default.tsx | 10 +- 4 files changed, 160 insertions(+), 24 deletions(-) diff --git a/src/admin/components/elements/DuplicateDocument/index.scss b/src/admin/components/elements/DuplicateDocument/index.scss index e69de29bb2..a2c98b20e6 100644 --- a/src/admin/components/elements/DuplicateDocument/index.scss +++ b/src/admin/components/elements/DuplicateDocument/index.scss @@ -0,0 +1,22 @@ +@import '../../../scss/styles.scss'; + +.duplicate { + + &__modal { + @include blur-bg; + display: flex; + align-items: center; + height: 100%; + + .btn { + margin-right: $baseline; + } + } + + &__modal-template { + z-index: 1; + position: relative; + } + + +} diff --git a/src/admin/components/elements/DuplicateDocument/index.tsx b/src/admin/components/elements/DuplicateDocument/index.tsx index 5c59984e2a..7cccfaf825 100644 --- a/src/admin/components/elements/DuplicateDocument/index.tsx +++ b/src/admin/components/elements/DuplicateDocument/index.tsx @@ -1,39 +1,143 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { useHistory } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import { Modal, useModal } from '@faceless-ui/modal'; import { useConfig } from '../../utilities/Config'; import { Props } from './types'; import Button from '../Button'; -import { useForm } from '../../forms/Form/context'; +import { requests } from '../../../api'; +import { useForm, useFormModified } from '../../forms/Form/context'; +import MinimalTemplate from '../../templates/Minimal'; import './index.scss'; const baseClass = 'duplicate'; -const Duplicate: React.FC = ({ slug }) => { +const Duplicate: React.FC = ({ slug, collection, id }) => { const { push } = useHistory(); - const { getData } = useForm(); + const modified = useFormModified(); + const { toggle } = useModal(); + const { setModified } = useForm(); + const { serverURL, routes: { api }, localization } = useConfig(); const { routes: { admin } } = useConfig(); + const [hasClicked, setHasClicked] = useState(false); - const handleClick = useCallback(() => { - const data = getData(); + const modalSlug = `duplicate-${id}`; - push({ - pathname: `${admin}/collections/${slug}/create`, - state: { - data, - }, - }); - }, [push, getData, slug, admin]); + const handleClick = useCallback(async (override = false) => { + setHasClicked(true); + + if (modified && !override) { + toggle(modalSlug); + return; + } + + const create = async (locale?: string): Promise => { + const localeParam = locale ? `locale=${locale}` : ''; + const response = await requests.get(`${serverURL}${api}/${slug}/${id}?${localeParam}`); + const data = await response.json(); + const result = await requests.post(`${serverURL}${api}/${slug}`, { + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + const json = await result.json(); + + if (result.status === 201) { + return json.doc.id; + } + json.errors.forEach((error) => toast.error(error.message)); + return null; + }; + + let duplicateID; + if (localization) { + duplicateID = await create(localization.defaultLocale); + let abort = false; + localization.locales + .filter((locale) => locale !== localization.defaultLocale) + .forEach(async (locale) => { + if (!abort) { + const res = await requests.get(`${serverURL}${api}/${slug}/${id}?locale=${locale}`); + const localizedDoc = await res.json(); + const patchResult = await requests.patch(`${serverURL}${api}/${slug}/${duplicateID}?locale=${locale}`, { + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(localizedDoc), + }); + if (patchResult.status > 400) { + abort = true; + const json = await patchResult.json(); + json.errors.forEach((error) => toast.error(error.message)); + } + } + }); + if (abort) { + // delete the duplicate doc to prevent incomplete + await requests.delete(`${serverURL}${api}/${slug}/${id}`); + } + } else { + duplicateID = await create(); + } + + toast.success(`${collection.labels.singular} successfully duplicated.`, + { autoClose: 3000 }); + + const previousModifiedState = modified; + setModified(false); + setTimeout(() => { + push({ + pathname: `${admin}/collections/${slug}/${duplicateID}`, + }); + setModified(previousModifiedState); + }, 10); + }, [modified, localization, collection.labels.singular, setModified, toggle, modalSlug, serverURL, api, slug, id, push, admin]); + + const confirm = useCallback(async () => { + setHasClicked(false); + await handleClick(true); + }, [handleClick]); return ( - + + + { modified && hasClicked && ( + + +

Confirm duplicate

+

+ You have unsaved changes. Would you like to continue to duplicate? +

+ + +
+
+ ) } +
); }; diff --git a/src/admin/components/elements/DuplicateDocument/types.ts b/src/admin/components/elements/DuplicateDocument/types.ts index ac7846166d..ef5ca4f490 100644 --- a/src/admin/components/elements/DuplicateDocument/types.ts +++ b/src/admin/components/elements/DuplicateDocument/types.ts @@ -1,3 +1,7 @@ +import { SanitizedCollectionConfig } from '../../../../collections/config/types'; + export type Props = { - slug: string, + slug: string + collection: SanitizedCollectionConfig + id: string } diff --git a/src/admin/components/views/collections/Edit/Default.tsx b/src/admin/components/views/collections/Edit/Default.tsx index 319ee9a973..94b5acf5d0 100644 --- a/src/admin/components/views/collections/Edit/Default.tsx +++ b/src/admin/components/views/collections/Edit/Default.tsx @@ -142,8 +142,14 @@ const DefaultEditView: React.FC = (props) => { Create New - {!disableDuplicate && ( -
  • + {!disableDuplicate && isEditing && ( +
  • + +
  • )} )} From d58884312132e109ae3f6619be2e0d7bab3f3111 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 30 Aug 2022 14:43:44 -0700 Subject: [PATCH 075/130] fix: #981 --- src/admin/components/forms/Form/fieldReducer.ts | 2 +- .../components/forms/field-types/ConfirmPassword/index.tsx | 4 ++++ src/admin/components/forms/useField/index.tsx | 2 +- src/fields/validations.ts | 7 +++---- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/admin/components/forms/Form/fieldReducer.ts b/src/admin/components/forms/Form/fieldReducer.ts index cca9653630..2eca9905ca 100644 --- a/src/admin/components/forms/Form/fieldReducer.ts +++ b/src/admin/components/forms/Form/fieldReducer.ts @@ -64,7 +64,7 @@ function fieldReducer(state: Fields, action): Fields { case 'REMOVE': { const newState = { ...state }; - delete newState[action.path]; + if (newState[action.path]) delete newState[action.path]; return newState; } diff --git a/src/admin/components/forms/field-types/ConfirmPassword/index.tsx b/src/admin/components/forms/field-types/ConfirmPassword/index.tsx index 72f2a15c3e..1a1548be92 100644 --- a/src/admin/components/forms/field-types/ConfirmPassword/index.tsx +++ b/src/admin/components/forms/field-types/ConfirmPassword/index.tsx @@ -11,6 +11,10 @@ const ConfirmPassword: React.FC = () => { const password = getField('password'); const validate = useCallback((value) => { + if (!value) { + return 'This field is required'; + } + if (value === password?.value) { return true; } diff --git a/src/admin/components/forms/useField/index.tsx b/src/admin/components/forms/useField/index.tsx index e09a62c9cc..18a9f4d51d 100644 --- a/src/admin/components/forms/useField/index.tsx +++ b/src/admin/components/forms/useField/index.tsx @@ -13,7 +13,7 @@ const useField = (options: Options): FieldType => { path, validate, enableDebouncedValue, - disableFormData, + disableFormData = false, condition, } = options; diff --git a/src/fields/validations.ts b/src/fields/validations.ts index 06e1f65697..f61d36f140 100644 --- a/src/fields/validations.ts +++ b/src/fields/validations.ts @@ -285,10 +285,9 @@ export const relationship: Validate = async }); if (invalidRelationships.length > 0) { - return `This field has the following invalid selections: ${ - invalidRelationships.map((err, invalid) => { - return `${err} ${JSON.stringify(invalid)}`; - }).join(', ')}` as string; + return `This field has the following invalid selections: ${invalidRelationships.map((err, invalid) => { + return `${err} ${JSON.stringify(invalid)}`; + }).join(', ')}` as string; } } From f517cb5e934f8988526757a3d04f078df4f0de4f Mon Sep 17 00:00:00 2001 From: James Date: Tue, 30 Aug 2022 15:07:38 -0700 Subject: [PATCH 076/130] chore: cleanup --- .../components/elements/DuplicateDocument/index.tsx | 7 +++---- test/admin/e2e.spec.ts | 11 ----------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/admin/components/elements/DuplicateDocument/index.tsx b/src/admin/components/elements/DuplicateDocument/index.tsx index 7cccfaf825..ba48cc4cdd 100644 --- a/src/admin/components/elements/DuplicateDocument/index.tsx +++ b/src/admin/components/elements/DuplicateDocument/index.tsx @@ -85,13 +85,12 @@ const Duplicate: React.FC = ({ slug, collection, id }) => { toast.success(`${collection.labels.singular} successfully duplicated.`, { autoClose: 3000 }); - const previousModifiedState = modified; setModified(false); + setTimeout(() => { push({ pathname: `${admin}/collections/${slug}/${duplicateID}`, }); - setModified(previousModifiedState); }, 10); }, [modified, localization, collection.labels.singular, setModified, toggle, modalSlug, serverURL, api, slug, id, push, admin]); @@ -110,7 +109,7 @@ const Duplicate: React.FC = ({ slug, collection, id }) => { > Duplicate - { modified && hasClicked && ( + {modified && hasClicked && ( = ({ slug, collection, id }) => { - ) } + )} ); }; diff --git a/test/admin/e2e.spec.ts b/test/admin/e2e.spec.ts index 0258f4a712..175dc86a30 100644 --- a/test/admin/e2e.spec.ts +++ b/test/admin/e2e.spec.ts @@ -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)); From af1a483e6445c403befebc19437efea7c8b131c9 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 30 Aug 2022 15:12:23 -0700 Subject: [PATCH 077/130] chore: ensures new Edit view is mounted when going directly between one id and another --- src/admin/components/Routes.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/admin/components/Routes.tsx b/src/admin/components/Routes.tsx index 2e981b9cc0..918813da66 100644 --- a/src/admin/components/Routes.tsx +++ b/src/admin/components/Routes.tsx @@ -208,6 +208,7 @@ const Routes = () => { if (permissions?.collections?.[collection.slug]?.read?.permission) { return ( From f1a272f4073c003929c1447870873d9a58999be0 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 30 Aug 2022 15:21:59 -0700 Subject: [PATCH 078/130] 1.0.31 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e29ca2800a..30eee83685 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "1.0.30", + "version": "1.0.31", "description": "Node, React and MongoDB Headless CMS and Application Framework", "license": "MIT", "author": { From 21417c65987c002ced7165f10fc448360d6a8e83 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 30 Aug 2022 17:58:17 -0700 Subject: [PATCH 079/130] chore: better logic for changing password in ui --- .../views/collections/Edit/Auth/index.tsx | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/admin/components/views/collections/Edit/Auth/index.tsx b/src/admin/components/views/collections/Edit/Auth/index.tsx index d0032d4f09..4dc23c03a4 100644 --- a/src/admin/components/views/collections/Edit/Auth/index.tsx +++ b/src/admin/components/views/collections/Edit/Auth/index.tsx @@ -18,17 +18,11 @@ const baseClass = 'auth-fields'; const Auth: React.FC = (props) => { const { useAPIKey, requirePassword, verify, collection: { slug }, collection, email, operation } = props; const [changingPassword, setChangingPassword] = useState(requirePassword); - const { getField } = useWatchForm(); + const { getField, dispatchFields } = useWatchForm(); const modified = useFormModified(); const enableAPIKey = getField('enableAPIKey'); - useEffect(() => { - if (!modified) { - setChangingPassword(false); - } - }, [modified]); - const { serverURL, routes: { @@ -36,6 +30,15 @@ const Auth: React.FC = (props) => { }, } = useConfig(); + const handleChangePassword = useCallback(async (state: boolean) => { + if (!state) { + dispatchFields({ type: 'REMOVE', path: 'password' }); + dispatchFields({ type: 'REMOVE', path: 'confirm-password' }); + } + + setChangingPassword(state); + }, [dispatchFields]); + const unlock = useCallback(async () => { const url = `${serverURL}${api}/${slug}/unlock`; const response = await fetch(url, { @@ -55,6 +58,12 @@ const Auth: React.FC = (props) => { } }, [serverURL, api, slug, email]); + useEffect(() => { + if (!modified) { + setChangingPassword(false); + } + }, [modified]); + if (collection.auth.disableLocalStrategy) { return null; } @@ -80,7 +89,7 @@ const Auth: React.FC = (props) => { @@ -91,7 +100,7 @@ const Auth: React.FC = (props) => { From a1f2dcab8f0ea8e4e3e4c34ab1b9c766ca8c2618 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Thu, 1 Sep 2022 10:26:41 -0400 Subject: [PATCH 080/130] docs: fix defaultValue example (#1044) --- docs/fields/overview.mdx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/fields/overview.mdx b/docs/fields/overview.mdx index 681d73e260..9442f7f359 100644 --- a/docs/fields/overview.mdx +++ b/docs/fields/overview.mdx @@ -225,11 +225,9 @@ const translation: { const field = { name: 'attribution', type: 'text', - admin: { - // highlight-start - defaultValue: ({ user, locale }) => (`${translation[locale]} ${user.name}`) - // highlight-end - } + // highlight-start + defaultValue: ({ user, locale }) => (`${translation[locale]} ${user.name}`) + // highlight-end }; ``` From 7dbcd9ca89dedf5aeda05651b08378ef3d698889 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Thu, 1 Sep 2022 12:12:29 -0400 Subject: [PATCH 081/130] docs: fix relationship where query --- docs/fields/relationship.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/fields/relationship.mdx b/docs/fields/relationship.mdx index a11f079176..a64122d077 100644 --- a/docs/fields/relationship.mdx +++ b/docs/fields/relationship.mdx @@ -78,7 +78,7 @@ const relationshipField = { // returns a Where query dynamically by the type of relationship if (relationTo === 'products') { return { - 'stock': { is_greater_than: siblingData.quantity } + 'stock': { greater_than: siblingData.quantity } } } From 482cbe71c7b1d39b665fb0b29a7a0b69f454180a Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Thu, 1 Sep 2022 12:17:53 -0400 Subject: [PATCH 082/130] feat: update operator type with contains (#1045) --- src/types/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/types/index.ts b/src/types/index.ts index 27df091e4a..7bfd57a1ba 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,7 +4,9 @@ import { FileData } from '../uploads/types'; export { PayloadRequest } from '../express/types'; -export type Operator = 'equals' +export type Operator = + | 'equals' + | 'contains' | 'not_equals' | 'in' | 'not_in' From 32a4e8e9b96b581a264932db127fb26335bf7f16 Mon Sep 17 00:00:00 2001 From: Ikko Ashimine Date: Fri, 2 Sep 2022 01:58:09 +0900 Subject: [PATCH 083/130] docs: update overview.mdx (#1046) --- docs/plugins/overview.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/overview.mdx b/docs/plugins/overview.mdx index 5582f5bf97..180d19f755 100644 --- a/docs/plugins/overview.mdx +++ b/docs/plugins/overview.mdx @@ -136,6 +136,6 @@ export default addLastModified; #### Available Plugins -You can discover existing plugins by browsing the `payload-plugin` topic on [Github](https://github.com/topics/payload-plugin). +You can discover existing plugins by browsing the `payload-plugin` topic on [GitHub](https://github.com/topics/payload-plugin). For maintainers building plugins for others to use, please add the topic to help others find it. If you would like one to be built by the core Payload team, [open a Feature Request](https://github.com/payloadcms/payload/discussions) in our GitHub Discussions board. We would be happy to review your code and maybe feature you and your plugin where appropriate. From c3a0bd86254dfc3f49e46d4e41bdf717424ea342 Mon Sep 17 00:00:00 2001 From: Wesley Date: Thu, 1 Sep 2022 19:03:21 +0200 Subject: [PATCH 084/130] fix: implement the same word boundary search as the like query (#1038) Co-authored-by: Dan Ribbens --- .../components/elements/ReactSelect/index.tsx | 3 ++ .../components/elements/ReactSelect/types.ts | 8 +++++ .../forms/field-types/Relationship/index.tsx | 17 +++++++++++ src/mongoose/sanitizeFormattedValue.ts | 9 ++---- src/utilities/wordBoundariesRegex.ts | 7 +++++ test/fields-relationship/config.ts | 16 ++++++---- test/fields-relationship/e2e.spec.ts | 30 +++++++++++++++++-- 7 files changed, 76 insertions(+), 14 deletions(-) create mode 100644 src/utilities/wordBoundariesRegex.ts diff --git a/src/admin/components/elements/ReactSelect/index.tsx b/src/admin/components/elements/ReactSelect/index.tsx index 6d98c83be4..7d879a4f54 100644 --- a/src/admin/components/elements/ReactSelect/index.tsx +++ b/src/admin/components/elements/ReactSelect/index.tsx @@ -61,6 +61,7 @@ const ReactSelect: React.FC = (props) => { isClearable, isMulti, isSortable, + filterOption = undefined, } = props; const classes = [ @@ -108,6 +109,7 @@ const ReactSelect: React.FC = (props) => { MultiValueLabel: SortableMultiValueLabel, DropdownIndicator: Chevron, }} + filterOption={filterOption} /> ); } @@ -125,6 +127,7 @@ const ReactSelect: React.FC = (props) => { options={options} isSearchable={isSearchable} isClearable={isClearable} + filterOption={filterOption} /> ); }; diff --git a/src/admin/components/elements/ReactSelect/types.ts b/src/admin/components/elements/ReactSelect/types.ts index 0d0a7649fa..d4ac8e0995 100644 --- a/src/admin/components/elements/ReactSelect/types.ts +++ b/src/admin/components/elements/ReactSelect/types.ts @@ -2,6 +2,11 @@ import { OptionsType, GroupedOptionsType } from 'react-select'; export type Options = OptionsType | GroupedOptionsType; +export type OptionType = { + [key: string]: any, +}; + + export type Value = { label: string value: string | null @@ -23,4 +28,7 @@ export type Props = { placeholder?: string isSearchable?: boolean isClearable?: boolean + filterOption?: + | (({ label, value, data }: { label: string, value: string, data: OptionType }, search: string) => boolean) + | undefined, } diff --git a/src/admin/components/forms/field-types/Relationship/index.tsx b/src/admin/components/forms/field-types/Relationship/index.tsx index 81f6b203c5..22b58ee3ed 100644 --- a/src/admin/components/forms/field-types/Relationship/index.tsx +++ b/src/admin/components/forms/field-types/Relationship/index.tsx @@ -22,6 +22,7 @@ import { createRelationMap } from './createRelationMap'; import { useDebouncedCallback } from '../../../../hooks/useDebouncedCallback'; import { useDocumentInfo } from '../../../utilities/DocumentInfo'; import { getFilterOptionsQuery } from '../getFilterOptionsQuery'; +import wordBoundariesRegex from '../../../../../utilities/wordBoundariesRegex'; import './index.scss'; @@ -70,6 +71,7 @@ const Relationship: React.FC = (props) => { const [optionFilters, setOptionFilters] = useState<{[relation: string]: Where}>(); const [hasLoadedValueOptions, setHasLoadedValueOptions] = useState(false); const [search, setSearch] = useState(''); + const [enableWordBoundarySearch, setEnableWordBoundarySearch] = useState(false); const memoizedValidate = useCallback((value, validationOptions) => { return validate(value, { ...validationOptions, required }); @@ -322,6 +324,17 @@ const Relationship: React.FC = (props) => { } }, [initialValue, getResults, optionFilters, filterOptions]); + // Determine if we should switch to word boundary search + useEffect(() => { + const relations = Array.isArray(relationTo) ? relationTo : [relationTo]; + const isIdOnly = relations.reduce((idOnly, relation) => { + const collection = collections.find((coll) => coll.slug === relation); + const fieldToSearch = collection?.admin?.useAsTitle || 'id'; + return fieldToSearch === 'id' && idOnly; + }, true); + setEnableWordBoundarySearch(!isIdOnly); + }, [relationTo, collections]); + const classes = [ 'field-type', baseClass, @@ -392,6 +405,10 @@ const Relationship: React.FC = (props) => { options={options} isMulti={hasMany} isSortable={isSortable} + filterOption={enableWordBoundarySearch ? (item, searchFilter) => { + const r = wordBoundariesRegex(searchFilter || ''); + return r.test(item.label); + } : undefined} /> )} {errorLoading && ( diff --git a/src/mongoose/sanitizeFormattedValue.ts b/src/mongoose/sanitizeFormattedValue.ts index 102ba11be4..2bd9ba5869 100644 --- a/src/mongoose/sanitizeFormattedValue.ts +++ b/src/mongoose/sanitizeFormattedValue.ts @@ -1,6 +1,7 @@ import mongoose, { SchemaType } from 'mongoose'; import { createArrayFromCommaDelineated } from './createArrayFromCommaDelineated'; import { getSchemaTypeOptions } from './getSchemaTypeOptions'; +import wordBoundariesRegex from '../utilities/wordBoundariesRegex'; export const sanitizeQueryValue = (schemaType: SchemaType, path: string, operator: string, val: any): unknown => { let formattedValue = val; @@ -96,12 +97,8 @@ export const sanitizeQueryValue = (schemaType: SchemaType, path: string, operato } if (operator === 'like' && typeof formattedValue === 'string') { - const words = formattedValue.split(' '); - const regex = words.reduce((pattern, word, i) => { - return `${pattern}(?=.*\\b${word}.*\\b)${i + 1 === words.length ? '.+' : ''}`; - }, ''); - - formattedValue = { $regex: new RegExp(regex), $options: 'i' }; + const $regex = wordBoundariesRegex(formattedValue) + formattedValue = { $regex }; } } diff --git a/src/utilities/wordBoundariesRegex.ts b/src/utilities/wordBoundariesRegex.ts new file mode 100644 index 0000000000..ff6d7cee5a --- /dev/null +++ b/src/utilities/wordBoundariesRegex.ts @@ -0,0 +1,7 @@ +export default (input: string): RegExp => { + const words = input.split(' '); + const regex = words.reduce((pattern, word, i) => { + return `${pattern}(?=.*\\b${word}.*\\b)${i + 1 === words.length ? '.+' : ''}`; + }, ''); + return new RegExp(regex, 'i'); +}; diff --git a/test/fields-relationship/config.ts b/test/fields-relationship/config.ts index 1a12ef7ff1..1ad43455eb 100644 --- a/test/fields-relationship/config.ts +++ b/test/fields-relationship/config.ts @@ -156,18 +156,22 @@ export default buildConfig({ name: 'relation-restricted', }, }); - const { id: relationWithTitleDocId } = await payload.create({ - collection: relationWithTitleSlug, - data: { - name: 'relation-title', - }, + const relationsWithTitle = []; + await mapAsync(['relation-title', 'word boundary search'], async (title) => { + const { id } = await payload.create({ + collection: relationWithTitleSlug, + data: { + name: title, + }, + }); + relationsWithTitle.push(id); }); await payload.create({ collection: slug, data: { relationship: relationOneDocId, relationshipRestricted: restrictedDocId, - relationshipWithTitle: relationWithTitleDocId, + relationshipWithTitle: relationsWithTitle[0], }, }); await mapAsync([...Array(11)], async () => { diff --git a/test/fields-relationship/e2e.spec.ts b/test/fields-relationship/e2e.spec.ts index 45ac25bc11..8c096a55ba 100644 --- a/test/fields-relationship/e2e.spec.ts +++ b/test/fields-relationship/e2e.spec.ts @@ -81,6 +81,14 @@ describe('fields - relationship', () => { }, }); + // Doc with useAsTitle for word boundary test + await payload.create({ + collection: relationWithTitleSlug, + data: { + name: 'word boundary search', + }, + }); + // Add restricted doc as relation docWithExistingRelations = await payload.create({ 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 () => { From 29e82ec845f69bf5a09b682739e88529ebc53c16 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Fri, 2 Sep 2022 14:02:58 -0400 Subject: [PATCH 085/130] fix: children of conditional fields required in graphql schema (#1055) --- src/graphql/schema/buildObjectType.ts | 30 +++++++++++++++++++++++---- src/graphql/schema/isFieldNullable.ts | 9 ++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 src/graphql/schema/isFieldNullable.ts diff --git a/src/graphql/schema/buildObjectType.ts b/src/graphql/schema/buildObjectType.ts index 46b4d3a930..40cfa5eb15 100644 --- a/src/graphql/schema/buildObjectType.ts +++ b/src/graphql/schema/buildObjectType.ts @@ -16,7 +16,28 @@ import { GraphQLUnionType, } from 'graphql'; import { DateTimeResolver, EmailAddressResolver } from 'graphql-scalars'; -import { Field, RadioField, RelationshipField, SelectField, UploadField, ArrayField, GroupField, RichTextField, NumberField, TextField, EmailField, TextareaField, CodeField, DateField, PointField, CheckboxField, BlockField, RowField, CollapsibleField, TabsField } from '../../fields/config/types'; +import { + Field, + RadioField, + RelationshipField, + SelectField, + UploadField, + ArrayField, + GroupField, + RichTextField, + NumberField, + TextField, + EmailField, + TextareaField, + CodeField, + DateField, + PointField, + CheckboxField, + BlockField, + RowField, + CollapsibleField, + TabsField, +} from '../../fields/config/types'; import formatName from '../utilities/formatName'; import combineParentName from '../utilities/combineParentName'; import withNullableType from './withNullableType'; @@ -26,6 +47,7 @@ import formatOptions from '../utilities/formatOptions'; import { Payload } from '../..'; import buildWhereInputType from './buildWhereInputType'; import buildBlockType from './buildBlockType'; +import isFieldNullable from './isFieldNullable'; type LocaleInputType = { locale: { @@ -397,7 +419,7 @@ function buildObjectType({ name: fullName, fields: field.fields, parentName: fullName, - forceNullable, + forceNullable: isFieldNullable(field, forceNullable), }); const arrayType = new GraphQLList(new GraphQLNonNull(type)); @@ -414,7 +436,7 @@ function buildObjectType({ name: fullName, parentName: fullName, fields: field.fields, - forceNullable, + forceNullable: isFieldNullable(field, forceNullable), }); return { @@ -427,7 +449,7 @@ function buildObjectType({ buildBlockType({ payload, block, - forceNullable, + forceNullable: isFieldNullable(field, forceNullable), }); return payload.types.blockTypes[block.slug]; }); diff --git a/src/graphql/schema/isFieldNullable.ts b/src/graphql/schema/isFieldNullable.ts new file mode 100644 index 0000000000..c9915490d0 --- /dev/null +++ b/src/graphql/schema/isFieldNullable.ts @@ -0,0 +1,9 @@ +import { FieldAffectingData, fieldAffectsData } from '../../fields/config/types'; + +const isFieldNullable = (field: FieldAffectingData, force: boolean): boolean => { + const hasReadAccessControl = field.access && field.access.read; + const condition = field.admin && field.admin.condition; + return !(force && fieldAffectsData(field) && field.required && !field.localized && !condition && !hasReadAccessControl); +}; + +export default isFieldNullable; From 254636167d35a8be32c0b7234e3d2d58c05c7598 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Fri, 2 Sep 2022 14:03:49 -0400 Subject: [PATCH 086/130] test: custom css (#1053) --- test/admin/config.ts | 2 ++ test/admin/e2e.spec.ts | 8 ++++++++ test/admin/styles.scss | 6 ++++++ 3 files changed, 16 insertions(+) create mode 100644 test/admin/styles.scss diff --git a/test/admin/config.ts b/test/admin/config.ts index 1dd9c8bc51..c8dd44225e 100644 --- a/test/admin/config.ts +++ b/test/admin/config.ts @@ -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: [ diff --git a/test/admin/e2e.spec.ts b/test/admin/e2e.spec.ts index 175dc86a30..e88b29bc44 100644 --- a/test/admin/e2e.spec.ts +++ b/test/admin/e2e.spec.ts @@ -225,6 +225,14 @@ describe('admin', () => { }); }); + 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 () => { diff --git a/test/admin/styles.scss b/test/admin/styles.scss new file mode 100644 index 0000000000..85b0ab6e37 --- /dev/null +++ b/test/admin/styles.scss @@ -0,0 +1,6 @@ +.nav__controls { + font-family: monospace; +} +.nav__controls:before { + content: 'custom-css'; +} From 44b0073834830a9d645a11bcafab3869b4eb1899 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Fri, 2 Sep 2022 15:21:09 -0400 Subject: [PATCH 087/130] fix: update removing a relationship with null (#1056) --- src/fields/validations.ts | 2 +- test/relationships/int.spec.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/fields/validations.ts b/src/fields/validations.ts index f61d36f140..b1913b98ae 100644 --- a/src/fields/validations.ts +++ b/src/fields/validations.ts @@ -252,7 +252,7 @@ export const relationship: Validate = async return defaultMessage; } - if (!canUseDOM && typeof value !== 'undefined') { + if (!canUseDOM && typeof value !== 'undefined' && value !== null) { const values = Array.isArray(value) ? value : [value]; const invalidRelationships = values.filter((val) => { diff --git a/test/relationships/int.spec.ts b/test/relationships/int.spec.ts index 8757050f87..7eadd63a83 100644 --- a/test/relationships/int.spec.ts +++ b/test/relationships/int.spec.ts @@ -184,6 +184,18 @@ describe('Relationships', () => { customIdNumberRelation: 'bad-input', })).rejects.toThrow('The following field is invalid: customIdNumberRelation'); }); + + it('should allow update removing a relationship', async () => { + const result = await client.update({ + slug, + id: post.id, + data: { + relationField: null, + }, + }); + + expect(result.status).toEqual(200); + }); }); describe('depth', () => { From afa03789b8d1e73d136c47b5344ac09a98da4484 Mon Sep 17 00:00:00 2001 From: Dustin Miller <1342542+spdustin@users.noreply.github.com> Date: Fri, 2 Sep 2022 14:27:06 -0500 Subject: [PATCH 088/130] =?UTF-8?q?docs:=20Correct=20=E2=80=9Cautheticate?= =?UTF-8?q?=E2=80=9D=20misspelling=20in=20docs=20(#1048)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/authentication/overview.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/authentication/overview.mdx b/docs/authentication/overview.mdx index d4b392d593..a5997a4a40 100644 --- a/docs/authentication/overview.mdx +++ b/docs/authentication/overview.mdx @@ -62,7 +62,7 @@ const Admins: CollectionConfig = { } ``` -**By enabling Authetication on a config, the following modifications will automatically be made to your Collection:** +**By enabling Authentication on a config, the following modifications will automatically be made to your Collection:** 1. `email` as well as password `salt` & `hash` fields will be added to your Collection's schema 1. The Admin panel will feature a new set of corresponding UI to allow for changing password and editing email From 5ae666b0e08b128bdf2d576428e8638c2b8c2ed8 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 2 Sep 2022 14:39:41 -0700 Subject: [PATCH 089/130] fix: ensures adding new media to upload works when existing doc does not exist --- src/admin/components/forms/field-types/Upload/Input.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/admin/components/forms/field-types/Upload/Input.tsx b/src/admin/components/forms/field-types/Upload/Input.tsx index 807ebce089..8ac4399206 100644 --- a/src/admin/components/forms/field-types/Upload/Input.tsx +++ b/src/admin/components/forms/field-types/Upload/Input.tsx @@ -153,7 +153,10 @@ const UploadInput: React.FC = (props) => { collection, slug: addModalSlug, fieldTypes, - setValue: onChange, + setValue: (e) => { + setMissingFile(false); + onChange(e); + }, }} /> Date: Fri, 2 Sep 2022 14:56:00 -0700 Subject: [PATCH 090/130] chore: bump version --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 30eee83685..55474fed5d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "1.0.31", + "version": "1.0.32", "description": "Node, React and MongoDB Headless CMS and Application Framework", "license": "MIT", "author": { @@ -298,4 +298,4 @@ "publishConfig": { "registry": "https://registry.npmjs.org/" } -} +} \ No newline at end of file From c0c093d16cd3a0c2d5271ab7442a421549f9f604 Mon Sep 17 00:00:00 2001 From: pixelistik Date: Tue, 6 Sep 2022 03:03:48 +0200 Subject: [PATCH 091/130] docs: Fix missing word (#1063) --- docs/getting-started/concepts.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started/concepts.mdx b/docs/getting-started/concepts.mdx index 2d9a53aba6..129f5908dc 100644 --- a/docs/getting-started/concepts.mdx +++ b/docs/getting-started/concepts.mdx @@ -70,7 +70,7 @@ For more, visit the [Access Control documentation](/docs/access-control/overview You can specify population `depth` via query parameter in the REST API and by an option in the local API. *Depth has no effect in the GraphQL API, because there, depth is based on the shape of your queries.* It is also possible to limit the depth for specific `relation` and `upload` fields using the `maxDepth` property in your configuration. -**For example, let's look the following Collections:** `departments`, `users`, `posts` +**For example, let's look at the following Collections:** `departments`, `users`, `posts` ``` // type: 'relationship' fields are equal to 1 depth level From 5e21048457488c110d681196f4307087fe489e01 Mon Sep 17 00:00:00 2001 From: Matt Arnold Date: Tue, 6 Sep 2022 15:41:27 -0400 Subject: [PATCH 092/130] docs: update custom endpoints link (#1075) --- docs/configuration/overview.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/overview.mdx b/docs/configuration/overview.mdx index d013467ade..0f1523fc50 100644 --- a/docs/configuration/overview.mdx +++ b/docs/configuration/overview.mdx @@ -39,7 +39,7 @@ Payload is a *config-based*, code-first CMS and application framework. The Paylo | `rateLimit` | Control IP-based rate limiting for all Payload resources. Used to prevent DDoS attacks and [more](/docs/production/preventing-abuse#rate-limiting-requests). | | `hooks` | Tap into Payload-wide hooks. [More](/docs/hooks/overview) | | `plugins` | An array of Payload plugins. [More](/docs/plugins/overview) | -| `endpoints` | An array of custom API endpoints added to the Payload router. [More](/docs/plugins/overview) | +| `endpoints` | An array of custom API endpoints added to the Payload router. [More](/docs/rest-api/overview#custom-endpoints) | #### Simple example From cd8edbaa1faa5a94166396918089a01058a4e75e Mon Sep 17 00:00:00 2001 From: Arick <57319837+EasonSoong@users.noreply.github.com> Date: Wed, 7 Sep 2022 04:19:21 +0800 Subject: [PATCH 093/130] fix: reorder plugin wrapping (#1051) --- src/admin/components/forms/field-types/RichText/RichText.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/admin/components/forms/field-types/RichText/RichText.tsx b/src/admin/components/forms/field-types/RichText/RichText.tsx index 43f2217d8c..6664f1630a 100644 --- a/src/admin/components/forms/field-types/RichText/RichText.tsx +++ b/src/admin/components/forms/field-types/RichText/RichText.tsx @@ -152,11 +152,11 @@ const RichText: React.FC = (props) => { ), ); + CreatedEditor = withHTML(CreatedEditor); + CreatedEditor = enablePlugins(CreatedEditor, elements); CreatedEditor = enablePlugins(CreatedEditor, leaves); - CreatedEditor = withHTML(CreatedEditor); - return CreatedEditor; }, [elements, leaves]); From 77ab11bce8a3ee509af57968adb0afecb1a2d049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?max=20fr=C3=BChsch=C3=BCtz?= Date: Tue, 6 Sep 2022 22:19:47 +0200 Subject: [PATCH 094/130] docs: move description prop doc to right place (#1077) --- docs/configuration/collections.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/collections.mdx b/docs/configuration/collections.mdx index 1fb4f40e72..76376a0752 100644 --- a/docs/configuration/collections.mdx +++ b/docs/configuration/collections.mdx @@ -17,7 +17,6 @@ It's often best practice to write your Collections in separate files and then im | **`slug`** * | Unique, URL-friendly string that will act as an identifier for this Collection. | | **`fields`** * | Array of field types that will determine the structure and functionality of the data stored within this Collection. [Click here](/docs/fields/overview) for a full list of field types as well as how to configure them. | | **`labels`** | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. | -| **`description`**| Text or React component to display below the Collection label in the List view to give editors more information. | | **`admin`** | Admin-specific configuration. See below for [more detail](#admin-options). | | **`hooks`** | Entry points to "tie in" to Collection actions at specific points. [More](/docs/hooks/overview#collection-hooks) | | **`access`** | Provide access control functions to define exactly who should be able to do what with Documents in this Collection. [More](/docs/access-control/overview/#collections) | @@ -63,6 +62,7 @@ You can customize the way that the Admin panel behaves on a collection-by-collec | Option | Description | | ---------------------------- | -------------| | `useAsTitle` | Specify a top-level field to use for a document title throughout the Admin panel. If no field is defined, the ID of the document is used as the title. | +| `description` | Text or React component to display below the Collection label in the List view to give editors more information. | | `defaultColumns` | Array of field names that correspond to which columns to show by default in this collection's List view. | | `disableDuplicate ` | Disables the "Duplicate" button while editing documents within this collection. | | `enableRichTextRelationship` | The [Rich Text](/docs/fields/rich-text) field features a `Relationship` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. | From 2ee4c7ad727b9311578d3049660de81c27dace55 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Tue, 6 Sep 2022 16:19:59 -0400 Subject: [PATCH 095/130] fix: update removing an upload with null (#1076) --- src/fields/validations.ts | 2 +- test/relationships/int.spec.ts | 1 + test/uploads/int.spec.ts | 31 ++++++++++++++++++++++++++++++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/fields/validations.ts b/src/fields/validations.ts index b1913b98ae..87da3352e6 100644 --- a/src/fields/validations.ts +++ b/src/fields/validations.ts @@ -235,7 +235,7 @@ export const upload: Validate = (value: string, o return defaultMessage; } - if (!canUseDOM && typeof value !== 'undefined') { + if (!canUseDOM && typeof value !== 'undefined' && value !== null) { const idField = options.payload.collections[options.relationTo].config.fields.find((field) => fieldAffectsData(field) && field.name === 'id'); const type = getIDType(idField); diff --git a/test/relationships/int.spec.ts b/test/relationships/int.spec.ts index 7eadd63a83..e23c52b4ae 100644 --- a/test/relationships/int.spec.ts +++ b/test/relationships/int.spec.ts @@ -195,6 +195,7 @@ describe('Relationships', () => { }); expect(result.status).toEqual(200); + expect(result.doc.relationField).toBeNull(); }); }); diff --git a/test/uploads/int.spec.ts b/test/uploads/int.spec.ts index 18e941e58c..e580310e0b 100644 --- a/test/uploads/int.spec.ts +++ b/test/uploads/int.spec.ts @@ -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'))); From b7e5828adc7bc6602da7992b073b005b30aa896f Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Tue, 6 Sep 2022 16:47:57 -0400 Subject: [PATCH 096/130] feat: cyrillic like query support (#1078) --- src/mongoose/sanitizeFormattedValue.ts | 2 +- src/utilities/wordBoundariesRegex.ts | 7 ++++++- test/collections-rest/int.spec.ts | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/mongoose/sanitizeFormattedValue.ts b/src/mongoose/sanitizeFormattedValue.ts index 2bd9ba5869..d538a472e2 100644 --- a/src/mongoose/sanitizeFormattedValue.ts +++ b/src/mongoose/sanitizeFormattedValue.ts @@ -97,7 +97,7 @@ export const sanitizeQueryValue = (schemaType: SchemaType, path: string, operato } if (operator === 'like' && typeof formattedValue === 'string') { - const $regex = wordBoundariesRegex(formattedValue) + const $regex = wordBoundariesRegex(formattedValue); formattedValue = { $regex }; } } diff --git a/src/utilities/wordBoundariesRegex.ts b/src/utilities/wordBoundariesRegex.ts index ff6d7cee5a..a5d8d38450 100644 --- a/src/utilities/wordBoundariesRegex.ts +++ b/src/utilities/wordBoundariesRegex.ts @@ -1,7 +1,12 @@ export default (input: string): RegExp => { const words = input.split(' '); + + // Regex word boundaries that work for cyrillic characters - https://stackoverflow.com/a/47062016/1717697 + const wordBoundaryBefore = '(?:(?<=[^\\p{L}\\p{N}])|^)'; + const wordBoundaryAfter = '(?=[^\\p{L}\\p{N}]|$)'; + const regex = words.reduce((pattern, word, i) => { - return `${pattern}(?=.*\\b${word}.*\\b)${i + 1 === words.length ? '.+' : ''}`; + return `${pattern}(?=.*${wordBoundaryBefore}${word}.*${wordBoundaryAfter})${i + 1 === words.length ? '.+' : ''}`; }, ''); return new RegExp(regex, 'i'); }; diff --git a/test/collections-rest/int.spec.ts b/test/collections-rest/int.spec.ts index 65a3f37568..32ae8493ec 100644 --- a/test/collections-rest/int.spec.ts +++ b/test/collections-rest/int.spec.ts @@ -373,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({ + 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' }); From 05d1b141b22f66cb9007f20f2ae9d8e31db4f32f Mon Sep 17 00:00:00 2001 From: James Date: Tue, 6 Sep 2022 13:56:53 -0700 Subject: [PATCH 097/130] fix: #1062 --- .../forms/field-types/Relationship/index.tsx | 18 ++++++++++++++++-- .../field-types/Relationship/optionsReducer.ts | 4 ++-- .../forms/field-types/Relationship/types.ts | 1 - 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/admin/components/forms/field-types/Relationship/index.tsx b/src/admin/components/forms/field-types/Relationship/index.tsx index 22b58ee3ed..9d070b7d1e 100644 --- a/src/admin/components/forms/field-types/Relationship/index.tsx +++ b/src/admin/components/forms/field-types/Relationship/index.tsx @@ -1,5 +1,5 @@ import React, { - useCallback, useEffect, useState, useReducer, + useCallback, useEffect, useState, useReducer, useRef, } from 'react'; import equal from 'deep-equal'; import qs from 'qs'; @@ -68,10 +68,11 @@ const Relationship: React.FC = (props) => { const [lastFullyLoadedRelation, setLastFullyLoadedRelation] = useState(-1); const [lastLoadedPage, setLastLoadedPage] = useState(1); const [errorLoading, setErrorLoading] = useState(''); - const [optionFilters, setOptionFilters] = useState<{[relation: string]: Where}>(); + const [optionFilters, setOptionFilters] = useState<{ [relation: string]: Where }>(); const [hasLoadedValueOptions, setHasLoadedValueOptions] = useState(false); const [search, setSearch] = useState(''); const [enableWordBoundarySearch, setEnableWordBoundarySearch] = useState(false); + const firstRun = useRef(true); const memoizedValidate = useCallback((value, validationOptions) => { return validate(value, { ...validationOptions, required }); @@ -335,6 +336,19 @@ const Relationship: React.FC = (props) => { setEnableWordBoundarySearch(!isIdOnly); }, [relationTo, collections]); + + // When relationTo changes, reset relationship options + // Note - effect should not run on first run + useEffect(() => { + if (firstRun.current) { + firstRun.current = false; + return; + } + + dispatchOptions({ type: 'CLEAR' }); + setHasLoadedValueOptions(false); + }, [relationTo]); + const classes = [ 'field-type', baseClass, diff --git a/src/admin/components/forms/field-types/Relationship/optionsReducer.ts b/src/admin/components/forms/field-types/Relationship/optionsReducer.ts index f4485dc9c7..fc4de70606 100644 --- a/src/admin/components/forms/field-types/Relationship/optionsReducer.ts +++ b/src/admin/components/forms/field-types/Relationship/optionsReducer.ts @@ -25,7 +25,7 @@ const sortOptions = (options: Option[]): Option[] => options.sort((a: Option, b: const optionsReducer = (state: Option[], action: Action): Option[] => { switch (action.type) { case 'CLEAR': { - return action.required ? [] : [{ value: 'null', label: 'None' }]; + return []; } case 'ADD': { @@ -51,7 +51,7 @@ const optionsReducer = (state: Option[], action: Action): Option[] => { } return docs; }, - []), + []), ]; ids.forEach((id) => { diff --git a/src/admin/components/forms/field-types/Relationship/types.ts b/src/admin/components/forms/field-types/Relationship/types.ts index 511a783185..5540dc15d3 100644 --- a/src/admin/components/forms/field-types/Relationship/types.ts +++ b/src/admin/components/forms/field-types/Relationship/types.ts @@ -15,7 +15,6 @@ export type Option = { type CLEAR = { type: 'CLEAR' - required: boolean } type ADD = { From 888734dcdf775f416395f8830561c47235bb9019 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Tue, 6 Sep 2022 19:03:43 -0400 Subject: [PATCH 098/130] fix: accented label char sanitization for GraphQL (#1080) --- src/graphql/utilities/formatName.spec.ts | 18 ++++++++++++++++++ src/graphql/utilities/formatName.ts | 4 ++++ 2 files changed, 22 insertions(+) create mode 100644 src/graphql/utilities/formatName.spec.ts diff --git a/src/graphql/utilities/formatName.spec.ts b/src/graphql/utilities/formatName.spec.ts new file mode 100644 index 0000000000..528ff36a14 --- /dev/null +++ b/src/graphql/utilities/formatName.spec.ts @@ -0,0 +1,18 @@ +/* eslint-disable indent */ +/* eslint-disable jest/prefer-strict-equal */ +import formatName from './formatName'; + +describe('formatName', () => { + it.each` + char | expected + ${'á'} | ${'a'} + ${'è'} | ${'e'} + ${'í'} | ${'i'} + ${'ó'} | ${'o'} + ${'ú'} | ${'u'} + ${'ñ'} | ${'n'} + ${'ü'} | ${'u'} + `('should convert accented character: $char', ({ char, expected }) => { + expect(formatName(char)).toEqual(expected); + }); +}); diff --git a/src/graphql/utilities/formatName.ts b/src/graphql/utilities/formatName.ts index 979b9bf9cc..d1cde537bc 100644 --- a/src/graphql/utilities/formatName.ts +++ b/src/graphql/utilities/formatName.ts @@ -10,6 +10,10 @@ const formatName = (string: string): string => { } const formatted = sanitizedString + // Convert accented characters + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/\./g, '_') .replace(/-|\//g, '_') .replace(/\+/g, '_') From 91000d7fdaa9628650c737fc3f7f6a900b7447d4 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 6 Sep 2022 22:53:27 -0700 Subject: [PATCH 099/130] feat: improves rich text link ux --- src/admin/components/elements/Popup/index.tsx | 2 +- src/admin/components/forms/Error/index.scss | 3 +- src/admin/components/forms/Label/index.scss | 4 +- .../forms/field-types/RichText/RichText.tsx | 11 +- .../RichText/elements/indent/index.tsx | 4 - .../RichText/elements/link/Modal/index.scss | 29 ++ .../RichText/elements/link/Modal/index.tsx | 73 ++++++ .../RichText/elements/link/Modal/types.ts | 7 + .../RichText/elements/link/index.scss | 84 ++---- .../RichText/elements/link/index.tsx | 248 +++++++++++------- .../RichText/elements/link/shared.ts | 1 + .../RichText/elements/link/utilities.tsx | 38 +-- .../elements/relationship/Button/index.tsx | 7 +- .../field-types/RichText/elements/toggle.tsx | 4 - .../RichText/elements/upload/Button/index.tsx | 8 +- .../forms/field-types/RichText/types.ts | 4 - .../forms/field-types/Text/Input.tsx | 7 +- .../forms/field-types/Text/index.tsx | 2 + .../forms/field-types/Text/types.ts | 2 + src/admin/hooks/useThrottledEffect.tsx | 15 +- src/admin/index.tsx | 1 + src/admin/scss/vars.scss | 4 +- 22 files changed, 327 insertions(+), 231 deletions(-) create mode 100644 src/admin/components/forms/field-types/RichText/elements/link/Modal/index.scss create mode 100644 src/admin/components/forms/field-types/RichText/elements/link/Modal/index.tsx create mode 100644 src/admin/components/forms/field-types/RichText/elements/link/Modal/types.ts create mode 100644 src/admin/components/forms/field-types/RichText/elements/link/shared.ts diff --git a/src/admin/components/elements/Popup/index.tsx b/src/admin/components/elements/Popup/index.tsx index c283206c50..fbd4556a1b 100644 --- a/src/admin/components/elements/Popup/index.tsx +++ b/src/admin/components/elements/Popup/index.tsx @@ -144,7 +144,7 @@ const Popup: React.FC = (props) => { >
    = (props) => { return CreatedEditor; }, [elements, leaves]); - const onBlur = useCallback(() => { - editor.blurSelection = editor.selection; - }, [editor]); - useEffect(() => { if (!loaded) { const mergedElements = mergeCustomFunctions(elements, elementTypes); @@ -279,7 +275,6 @@ const RichText: React.FC = (props) => { placeholder={placeholder} spellCheck readOnly={readOnly} - onBlur={onBlur} onKeyDown={(event) => { if (event.key === 'Enter') { if (event.shiftKey) { @@ -289,7 +284,7 @@ const RichText: React.FC = (props) => { const selectedElement = Node.descendant(editor, editor.selection.anchor.path.slice(0, -1)); if (SlateElement.isElement(selectedElement)) { - // Allow hard enter to "break out" of certain elements + // Allow hard enter to "break out" of certain elements if (editor.shouldBreakOutOnEnter(selectedElement)) { event.preventDefault(); const selectedLeaf = Node.descendant(editor, editor.selection.anchor.path); diff --git a/src/admin/components/forms/field-types/RichText/elements/indent/index.tsx b/src/admin/components/forms/field-types/RichText/elements/indent/index.tsx index 50150ab873..a74daf3851 100644 --- a/src/admin/components/forms/field-types/RichText/elements/indent/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/indent/index.tsx @@ -24,10 +24,6 @@ const indent = { const handleIndent = useCallback((e, dir) => { e.preventDefault(); - if (editor.blurSelection) { - Transforms.select(editor, editor.blurSelection); - } - if (dir === 'left') { Transforms.unwrapNodes(editor, { match: (n) => Element.isElement(n) && [indentType, ...listTypes].includes(n.type), diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.scss b/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.scss new file mode 100644 index 0000000000..4d9eca42f4 --- /dev/null +++ b/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.scss @@ -0,0 +1,29 @@ +@import '../../../../../../../scss/styles.scss'; + +.rich-text-link-modal { + @include blur-bg; + display: flex; + align-items: center; + height: 100%; + + &__template { + position: relative; + z-index: 1; + } + + &__header { + width: 100%; + margin-bottom: $baseline; + display: flex; + justify-content: space-between; + + h3 { + margin: 0; + } + + svg { + width: base(1.5); + height: base(1.5); + } + } +} \ No newline at end of file diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.tsx b/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.tsx new file mode 100644 index 0000000000..af41fb3761 --- /dev/null +++ b/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.tsx @@ -0,0 +1,73 @@ +import { Modal } from '@faceless-ui/modal'; +import React, { useEffect, useRef } from 'react'; +import { MinimalTemplate } from '../../../../../..'; +import Button from '../../../../../../elements/Button'; +import X from '../../../../../../icons/X'; +import Form from '../../../../../Form'; +import FormSubmit from '../../../../../Submit'; +import Checkbox from '../../../../Checkbox'; +import Text from '../../../../Text'; +import { Props } from './types'; +import { modalSlug } from '../shared'; + +import './index.scss'; + +const baseClass = modalSlug; + +export const EditModal: React.FC = ({ close, handleModalSubmit, initialData }) => { + const inputRef = useRef(); + + useEffect(() => { + if (inputRef?.current) { + inputRef.current.focus(); + } + }, []); + + return ( + + +
    +

    Edit Link

    + +
    + + + + + + Confirm + + +
    +
    + ); +}; diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Modal/types.ts b/src/admin/components/forms/field-types/RichText/elements/link/Modal/types.ts new file mode 100644 index 0000000000..5e85b35cb0 --- /dev/null +++ b/src/admin/components/forms/field-types/RichText/elements/link/Modal/types.ts @@ -0,0 +1,7 @@ +import { Fields } from '../../../../../Form/types'; + +export type Props = { + close: () => void + handleModalSubmit: (fields: Fields, data: Record) => void + initialData?: Record +} diff --git a/src/admin/components/forms/field-types/RichText/elements/link/index.scss b/src/admin/components/forms/field-types/RichText/elements/link/index.scss index f830a7f01c..2eab975a86 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/index.scss +++ b/src/admin/components/forms/field-types/RichText/elements/link/index.scss @@ -11,13 +11,28 @@ bottom: 0; left: 0; } -} -.rich-text-link__popup-wrap { - cursor: pointer; + &__popup { + @extend %body; + font-family: var(--font-body); - .tooltip { - bottom: 80%; + button { + @extend %btn-reset; + margin-left: base(.5); + font-weight: 600; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + } + + &__goto-link { + max-width: base(6); + overflow: hidden; + text-overflow: ellipsis; + margin-right: base(.5); } } @@ -36,61 +51,4 @@ &--open { z-index: var(--z-popup); } -} - -.rich-text-link__url-wrap { - position: relative; - width: 100%; - margin-bottom: base(.5); -} - -.rich-text-link__confirm { - position: absolute; - right: base(.5); - top: 50%; - transform: translateY(-50%); - - svg { - @include color-svg(var(--theme-elevation-0)); - transform: rotate(-90deg); - } -} - -.rich-text-link__url { - @include formInput; - padding-right: base(1.75); - min-width: base(12); - width: 100%; - background: var(--theme-input-bg); - color: var(--theme-elevation-1000); -} - -.rich-text-link__new-tab { - svg { - @include color-svg(var(--theme-elevation-900)); - background: var(--theme-elevation-100); - margin-right: base(.5); - } - - path { - opacity: 0; - } - - &:hover { - path { - opacity: .2; - } - } - - &--checked { - path { - opacity: 1; - } - - &:hover { - path { - opacity: .8; - } - } - } -} +} \ No newline at end of file diff --git a/src/admin/components/forms/field-types/RichText/elements/link/index.tsx b/src/admin/components/forms/field-types/RichText/elements/link/index.tsx index 2966f8fe07..3548576e1d 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/index.tsx @@ -1,13 +1,14 @@ -import React, { Fragment, useCallback, useState } from 'react'; +import React, { Fragment, useCallback, useEffect, useState } from 'react'; import { ReactEditor, useSlate } from 'slate-react'; -import { Transforms } from 'slate'; +import { Transforms, Node, Editor, Range } from 'slate'; +import { useModal } from '@faceless-ui/modal'; import ElementButton from '../Button'; -import { withLinks, wrapLink } from './utilities'; +import { unwrapLink, withLinks } from './utilities'; import LinkIcon from '../../../../../icons/Link'; import Popup from '../../../../../elements/Popup'; -import Button from '../../../../../elements/Button'; -import Check from '../../../../../icons/Check'; -import Error from '../../../../Error'; +import { EditModal } from './Modal'; +import { modalSlug } from './shared'; +import isElementActive from '../isActive'; import './index.scss'; @@ -15,22 +16,24 @@ const baseClass = 'rich-text-link'; const Link = ({ attributes, children, element, editorRef }) => { const editor = useSlate(); - const [error, setError] = useState(false); - const [open, setOpen] = useState(element.url === undefined); + const { open, closeAll } = useModal(); + const [renderModal, setRenderModal] = useState(false); + const [renderPopup, setRenderPopup] = useState(false); + const [initialData, setInitialData] = useState>({}); - const handleToggleOpen = useCallback((newOpen) => { - setOpen(newOpen); - - if (element.url === undefined && !newOpen) { - const path = ReactEditor.findPath(editor, element); - - Transforms.setNodes( - editor, - { url: '' }, - { at: path }, - ); + const handleTogglePopup = useCallback((render) => { + if (!render) { + setRenderPopup(render); } - }, [editor, element]); + }, []); + + useEffect(() => { + setInitialData({ + newTab: element.newTab, + text: Node.string(element), + url: element.url, + }); + }, [renderModal, element]); return ( { style={{ userSelect: 'none' }} contentEditable={false} > + {renderModal && ( + { + closeAll(); + setRenderModal(false); + }} + handleModalSubmit={(_, data) => { + closeAll(); + setRenderModal(false); + Transforms.removeNodes(editor, { at: editor.selection.focus.path }); + Transforms.insertNodes( + editor, + { + type: 'link', + newTab: data.newTab, + url: data.url, + children: [ + { + text: String(data.text), + }, + ], + }, + ); + ReactEditor.focus(editor); + }} + initialData={initialData} + /> + )} ( - -
    - { - const { value } = e.target; - - if (value && error) { - setError(false); - } - - const path = ReactEditor.findPath(editor, element); - - Transforms.setNodes( - editor, - { url: value }, - { at: path }, - ); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - close(); - } - }} - /> -
    - -
    + Change + + +
    )} /> - + ); }; const LinkButton = () => { const editor = useSlate(); + const { open, closeAll } = useModal(); + const [renderModal, setRenderModal] = useState(false); + const [initialData, setInitialData] = useState>({}); return ( - wrapLink(editor)} - > - - + + { + if (isElementActive(editor, 'link')) { + unwrapLink(editor); + } else { + open(modalSlug); + setRenderModal(true); + + const isCollapsed = editor.selection && Range.isCollapsed(editor.selection); + + if (!isCollapsed) { + setInitialData({ + text: Editor.string(editor, editor.selection), + }); + } + } + }} + > + + + {renderModal && ( + { + closeAll(); + setRenderModal(false); + }} + handleModalSubmit={(_, data) => { + const isCollapsed = editor.selection && Range.isCollapsed(editor.selection); + + const newLink = { + type: 'link', + url: data.url, + newTab: data.newTab, + children: [{ text: String(data.text) }], + }; + + if (isCollapsed) { + Transforms.insertNodes(editor, newLink); + } else { + Transforms.wrapNodes(editor, newLink, { split: true }); + Transforms.collapse(editor, { edge: 'end' }); + Transforms.removeNodes(editor, { at: editor.selection.focus.path }); + Transforms.insertNodes(editor, newLink); + } + + closeAll(); + setRenderModal(false); + + ReactEditor.focus(editor); + }} + /> + )} + ); }; diff --git a/src/admin/components/forms/field-types/RichText/elements/link/shared.ts b/src/admin/components/forms/field-types/RichText/elements/link/shared.ts new file mode 100644 index 0000000000..e73642bb33 --- /dev/null +++ b/src/admin/components/forms/field-types/RichText/elements/link/shared.ts @@ -0,0 +1 @@ +export const modalSlug = 'rich-text-link-modal'; diff --git a/src/admin/components/forms/field-types/RichText/elements/link/utilities.tsx b/src/admin/components/forms/field-types/RichText/elements/link/utilities.tsx index f6f2f5b1e8..11fe101535 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/utilities.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/utilities.tsx @@ -1,37 +1,25 @@ import { Editor, Transforms, Range, Element } from 'slate'; -import isElementActive from '../isActive'; export const unwrapLink = (editor: Editor): void => { Transforms.unwrapNodes(editor, { match: (n) => Element.isElement(n) && n.type === 'link' }); }; -export const wrapLink = (editor: Editor, url?: string, newTab?: boolean): void => { - const { selection, blurSelection } = editor; +export const wrapLink = (editor: Editor): void => { + const { selection } = editor; + const isCollapsed = selection && Range.isCollapsed(selection); - if (blurSelection) { - Transforms.select(editor, blurSelection); - } + const link = { + type: 'link', + url: undefined, + newTab: false, + children: isCollapsed ? [{ text: '' }] : [], + }; - if (isElementActive(editor, 'link')) { - unwrapLink(editor); + if (isCollapsed) { + Transforms.insertNodes(editor, link); } else { - const selectionToUse = selection || blurSelection; - - const isCollapsed = selectionToUse && Range.isCollapsed(selectionToUse); - - const link = { - type: 'link', - url, - newTab, - children: isCollapsed ? [{ text: url }] : [], - }; - - if (isCollapsed) { - Transforms.insertNodes(editor, link); - } else { - Transforms.wrapNodes(editor, link, { split: true }); - Transforms.collapse(editor, { edge: 'end' }); - } + Transforms.wrapNodes(editor, link, { split: true }); + Transforms.collapse(editor, { edge: 'end' }); } }; diff --git a/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.tsx b/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.tsx index cc169b433e..1eb550cb09 100644 --- a/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.tsx @@ -1,6 +1,5 @@ import React, { Fragment, useCallback, useEffect, useState } from 'react'; import { Modal, useModal } from '@faceless-ui/modal'; -import { Transforms } from 'slate'; import { ReactEditor, useSlate } from 'slate-react'; import { useConfig } from '../../../../../../utilities/Config'; import ElementButton from '../../Button'; @@ -32,16 +31,12 @@ const insertRelationship = (editor, { value, relationTo }) => { ], }; - if (editor.blurSelection) { - Transforms.select(editor, editor.blurSelection); - } - injectVoidElement(editor, relationship); ReactEditor.focus(editor); }; -const RelationshipButton: React.FC<{path: string}> = ({ path }) => { +const RelationshipButton: React.FC<{ path: string }> = ({ path }) => { const { open, closeAll } = useModal(); const editor = useSlate(); const { serverURL, routes: { api }, collections } = useConfig(); diff --git a/src/admin/components/forms/field-types/RichText/elements/toggle.tsx b/src/admin/components/forms/field-types/RichText/elements/toggle.tsx index 6fe97b83b1..9c109f9582 100644 --- a/src/admin/components/forms/field-types/RichText/elements/toggle.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/toggle.tsx @@ -15,10 +15,6 @@ const toggleElement = (editor, format) => { type = 'li'; } - if (editor.blurSelection) { - Transforms.select(editor, editor.blurSelection); - } - Transforms.unwrapNodes(editor, { match: (n) => Element.isElement(n) && listTypes.includes(n.type as string), split: true, diff --git a/src/admin/components/forms/field-types/RichText/elements/upload/Button/index.tsx b/src/admin/components/forms/field-types/RichText/elements/upload/Button/index.tsx index d60596b192..7c6763a2bd 100644 --- a/src/admin/components/forms/field-types/RichText/elements/upload/Button/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/upload/Button/index.tsx @@ -36,22 +36,18 @@ const insertUpload = (editor, { value, relationTo }) => { ], }; - if (editor.blurSelection) { - Transforms.select(editor, editor.blurSelection); - } - injectVoidElement(editor, upload); ReactEditor.focus(editor); }; -const UploadButton: React.FC<{path: string}> = ({ path }) => { +const UploadButton: React.FC<{ path: string }> = ({ path }) => { const { open, closeAll, currentModal } = useModal(); const editor = useSlate(); const { serverURL, routes: { api }, collections } = useConfig(); const [availableCollections] = useState(() => collections.filter(({ admin: { enableRichTextRelationship }, upload }) => (Boolean(upload) && enableRichTextRelationship))); const [renderModal, setRenderModal] = useState(false); - const [modalCollectionOption, setModalCollectionOption] = useState<{ label: string, value: string}>(() => { + const [modalCollectionOption, setModalCollectionOption] = useState<{ label: string, value: string }>(() => { const firstAvailableCollection = collections.find(({ admin: { enableRichTextRelationship }, upload }) => (Boolean(upload) && enableRichTextRelationship)); if (firstAvailableCollection) { return { label: firstAvailableCollection.labels.singular, value: firstAvailableCollection.slug }; diff --git a/src/admin/components/forms/field-types/RichText/types.ts b/src/admin/components/forms/field-types/RichText/types.ts index 512d0f8634..ca1bc16113 100644 --- a/src/admin/components/forms/field-types/RichText/types.ts +++ b/src/admin/components/forms/field-types/RichText/types.ts @@ -4,7 +4,3 @@ import { RichTextField } from '../../../../../fields/config/types'; export type Props = Omit & { path?: string } - -export interface BlurSelectionEditor extends BaseEditor { - blurSelection?: Selection -} diff --git a/src/admin/components/forms/field-types/Text/Input.tsx b/src/admin/components/forms/field-types/Text/Input.tsx index 37e6a5eaa1..d9f2f74599 100644 --- a/src/admin/components/forms/field-types/Text/Input.tsx +++ b/src/admin/components/forms/field-types/Text/Input.tsx @@ -4,7 +4,6 @@ import Error from '../../Error'; import FieldDescription from '../../FieldDescription'; import { TextField } from '../../../../../fields/config/types'; import { Description } from '../../FieldDescription/types'; -// import { FieldType } from '../../useField/types'; import './index.scss'; @@ -17,10 +16,12 @@ export type TextInputProps = Omit & { value?: string description?: Description onChange?: (e: ChangeEvent) => void + onKeyDown?: React.KeyboardEventHandler placeholder?: string style?: React.CSSProperties className?: string width?: string + inputRef?: React.MutableRefObject } const TextInput: React.FC = (props) => { @@ -34,10 +35,12 @@ const TextInput: React.FC = (props) => { required, value, onChange, + onKeyDown, description, style, className, width, + inputRef, } = props; const classes = [ @@ -66,9 +69,11 @@ const TextInput: React.FC = (props) => { required={required} /> = (props) => { description, condition, } = {}, + inputRef, } = props; const path = pathFromProps || name; @@ -63,6 +64,7 @@ const Text: React.FC = (props) => { className={className} width={width} description={description} + inputRef={inputRef} /> ); }; diff --git a/src/admin/components/forms/field-types/Text/types.ts b/src/admin/components/forms/field-types/Text/types.ts index 1fa4e796a5..b28882b353 100644 --- a/src/admin/components/forms/field-types/Text/types.ts +++ b/src/admin/components/forms/field-types/Text/types.ts @@ -2,4 +2,6 @@ import { TextField } from '../../../../../fields/config/types'; export type Props = Omit & { path?: string + inputRef?: React.MutableRefObject + onKeyDown?: React.KeyboardEventHandler } diff --git a/src/admin/hooks/useThrottledEffect.tsx b/src/admin/hooks/useThrottledEffect.tsx index 79d1125b7e..362112870a 100644 --- a/src/admin/hooks/useThrottledEffect.tsx +++ b/src/admin/hooks/useThrottledEffect.tsx @@ -4,10 +4,10 @@ import { useEffect, useRef } from 'react'; type useThrottledEffect = (callback: React.EffectCallback, delay: number, deps: React.DependencyList) => void; const useThrottledEffect: useThrottledEffect = (callback, delay, deps = []) => { - const lastRan = useRef(Date.now()); + const lastRan = useRef(null); - useEffect( - () => { + useEffect(() => { + if (lastRan) { const handler = setTimeout(() => { if (Date.now() - lastRan.current >= delay) { callback(); @@ -18,9 +18,12 @@ const useThrottledEffect: useThrottledEffect = (callback, delay, deps = []) => { return () => { clearTimeout(handler); }; - }, - [delay, ...deps], - ); + } + + callback(); + lastRan.current = Date.now(); + return () => null; + }, [delay, ...deps]); }; export default useThrottledEffect; diff --git a/src/admin/index.tsx b/src/admin/index.tsx index f0b0eba94f..cb39841554 100644 --- a/src/admin/index.tsx +++ b/src/admin/index.tsx @@ -35,6 +35,7 @@ const Index = () => ( diff --git a/src/admin/scss/vars.scss b/src/admin/scss/vars.scss index 3fd3813ad5..3e8a8f17a6 100644 --- a/src/admin/scss/vars.scss +++ b/src/admin/scss/vars.scss @@ -129,7 +129,7 @@ $focus-box-shadow: 0 0 0 $style-stroke-width-m var(--theme-success-500); &:before { background: $color; - opacity: .9; + opacity: .85; } &:after { @@ -181,4 +181,4 @@ $focus-box-shadow: 0 0 0 $style-stroke-width-m var(--theme-success-500); border-color: var(--theme-elevation-150); } } -} +} \ No newline at end of file From 6d13ae684658291a0b6b4825a3c4e93bd5685ef8 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 6 Sep 2022 22:58:14 -0700 Subject: [PATCH 100/130] chore: removes old comment --- src/admin/components/elements/Popup/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/admin/components/elements/Popup/index.tsx b/src/admin/components/elements/Popup/index.tsx index fbd4556a1b..d44ad57298 100644 --- a/src/admin/components/elements/Popup/index.tsx +++ b/src/admin/components/elements/Popup/index.tsx @@ -144,7 +144,6 @@ const Popup: React.FC = (props) => { >
    Date: Wed, 7 Sep 2022 10:53:21 -0400 Subject: [PATCH 101/130] fix: require min 1 option in field schema validation (#1082) --- src/fields/config/schema.ts | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/fields/config/schema.ts b/src/fields/config/schema.ts index 255814587a..ada1bdfc55 100644 --- a/src/fields/config/schema.ts +++ b/src/fields/config/schema.ts @@ -131,13 +131,15 @@ export const code = baseField.keys({ export const select = baseField.keys({ type: joi.string().valid('select').required(), name: joi.string().required(), - options: joi.array().items(joi.alternatives().try( - joi.string(), - joi.object({ - value: joi.string().required().allow(''), - label: joi.string().required(), - }), - )).required(), + options: joi.array().min(1).items( + joi.alternatives().try( + joi.string(), + joi.object({ + value: joi.string().required().allow(''), + label: joi.string().required(), + }), + ), + ).required(), hasMany: joi.boolean().default(false), defaultValue: joi.alternatives().try( joi.string().allow(''), @@ -153,13 +155,15 @@ export const select = baseField.keys({ export const radio = baseField.keys({ type: joi.string().valid('radio').required(), name: joi.string().required(), - options: joi.array().items(joi.alternatives().try( - joi.string(), - joi.object({ - value: joi.string().required().allow(''), - label: joi.string().required(), - }), - )).required(), + options: joi.array().min(1).items( + joi.alternatives().try( + joi.string(), + joi.object({ + value: joi.string().required().allow(''), + label: joi.string().required(), + }), + ), + ).required(), defaultValue: joi.alternatives().try( joi.string().allow(''), joi.func(), From 0f671b1b354fd2aa3392b02da2ff4d6184f48858 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Wed, 7 Sep 2022 10:57:08 -0400 Subject: [PATCH 102/130] chore(release): v1.0.33 --- CHANGELOG.md | 24 ++++++++++++++++++++++++ package.json | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75703c4288..aa8641ab97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +## [1.0.33](https://github.com/payloadcms/payload/compare/v1.0.30...v1.0.33) (2022-09-07) + + +### Bug Fixes + +* [#1062](https://github.com/payloadcms/payload/issues/1062) ([05d1b14](https://github.com/payloadcms/payload/commit/05d1b141b22f66cb9007f20f2ae9d8e31db4f32f)) +* [#948](https://github.com/payloadcms/payload/issues/948) ([8df9ee7](https://github.com/payloadcms/payload/commit/8df9ee7b2dfcb2f77f049d02788a5c60c45f8c12)) +* [#981](https://github.com/payloadcms/payload/issues/981) ([d588843](https://github.com/payloadcms/payload/commit/d58884312132e109ae3f6619be2e0d7bab3f3111)) +* accented label char sanitization for GraphQL ([#1080](https://github.com/payloadcms/payload/issues/1080)) ([888734d](https://github.com/payloadcms/payload/commit/888734dcdf775f416395f8830561c47235bb9019)) +* children of conditional fields required in graphql schema ([#1055](https://github.com/payloadcms/payload/issues/1055)) ([29e82ec](https://github.com/payloadcms/payload/commit/29e82ec845f69bf5a09b682739e88529ebc53c16)) +* ensures adding new media to upload works when existing doc does not exist ([5ae666b](https://github.com/payloadcms/payload/commit/5ae666b0e08b128bdf2d576428e8638c2b8c2ed8)) +* implement the same word boundary search as the like query ([#1038](https://github.com/payloadcms/payload/issues/1038)) ([c3a0bd8](https://github.com/payloadcms/payload/commit/c3a0bd86254dfc3f49e46d4e41bdf717424ea342)) +* reorder plugin wrapping ([#1051](https://github.com/payloadcms/payload/issues/1051)) ([cd8edba](https://github.com/payloadcms/payload/commit/cd8edbaa1faa5a94166396918089a01058a4e75e)) +* require min 1 option in field schema validation ([#1082](https://github.com/payloadcms/payload/issues/1082)) ([d56882c](https://github.com/payloadcms/payload/commit/d56882cc20764b793049f20a91864c943e711375)) +* update removing a relationship with null ([#1056](https://github.com/payloadcms/payload/issues/1056)) ([44b0073](https://github.com/payloadcms/payload/commit/44b0073834830a9d645a11bcafab3869b4eb1899)) +* update removing an upload with null ([#1076](https://github.com/payloadcms/payload/issues/1076)) ([2ee4c7a](https://github.com/payloadcms/payload/commit/2ee4c7ad727b9311578d3049660de81c27dace55)) + + +### Features + +* cyrillic like query support ([#1078](https://github.com/payloadcms/payload/issues/1078)) ([b7e5828](https://github.com/payloadcms/payload/commit/b7e5828adc7bc6602da7992b073b005b30aa896f)) +* duplicate copies all locales ([51c7770](https://github.com/payloadcms/payload/commit/51c7770b10c34a3e40520ca8d64beedc67693c5c)) +* update operator type with contains ([#1045](https://github.com/payloadcms/payload/issues/1045)) ([482cbe7](https://github.com/payloadcms/payload/commit/482cbe71c7b1d39b665fb0b29a7a0b69f454180a)) + ## [1.0.30](https://github.com/payloadcms/payload/compare/v1.0.29...v1.0.30) (2022-08-30) diff --git a/package.json b/package.json index 55474fed5d..555e0b8d5d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "1.0.32", + "version": "1.0.33", "description": "Node, React and MongoDB Headless CMS and Application Framework", "license": "MIT", "author": { @@ -298,4 +298,4 @@ "publishConfig": { "registry": "https://registry.npmjs.org/" } -} \ No newline at end of file +} From 8bd2a0e6c9a9cd05c7b162ade47f3bb111236ba3 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 7 Sep 2022 08:58:02 -0700 Subject: [PATCH 103/130] fix: add height/width if imageSizes not specified --- src/uploads/getBaseFields.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/uploads/getBaseFields.ts b/src/uploads/getBaseFields.ts index 4681590d71..6deff4a3fb 100644 --- a/src/uploads/getBaseFields.ts +++ b/src/uploads/getBaseFields.ts @@ -92,6 +92,8 @@ const getBaseUploadFields = ({ config, collection }: Options): Field[] => { filename, mimeType, filesize, + width, + height, ]; if (uploadOptions.mimeTypes) { @@ -100,8 +102,6 @@ const getBaseUploadFields = ({ config, collection }: Options): Field[] => { if (uploadOptions.imageSizes) { uploadFields = uploadFields.concat([ - width, - height, { name: 'sizes', label: 'Sizes', From 784696f9a63d28745b9dac047079cc49e45a1fce Mon Sep 17 00:00:00 2001 From: James Date: Wed, 7 Sep 2022 09:27:39 -0700 Subject: [PATCH 104/130] chore: comments todos --- .../forms/field-types/RichText/elements/link/index.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/admin/components/forms/field-types/RichText/elements/link/index.tsx b/src/admin/components/forms/field-types/RichText/elements/link/index.tsx index 3548576e1d..e65e3e834a 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/index.tsx @@ -14,6 +14,8 @@ import './index.scss'; const baseClass = 'rich-text-link'; +// TODO: Multiple modal windows stacked go boom (rip). Edit Upload in fields -> rich text + const Link = ({ attributes, children, element, editorRef }) => { const editor = useSlate(); const { open, closeAll } = useModal(); @@ -53,6 +55,8 @@ const Link = ({ attributes, children, element, editorRef }) => { handleModalSubmit={(_, data) => { closeAll(); setRenderModal(false); + + // TODO: Inserts duplicate link node Transforms.removeNodes(editor, { at: editor.selection.focus.path }); Transforms.insertNodes( editor, @@ -179,6 +183,7 @@ const LinkButton = () => { if (isCollapsed) { Transforms.insertNodes(editor, newLink); } else { + // TODO: Inserts duplicate link node Transforms.wrapNodes(editor, newLink, { split: true }); Transforms.collapse(editor, { edge: 'end' }); Transforms.removeNodes(editor, { at: editor.selection.focus.path }); From b38b6427b8b813487922db0bb7d3762cc41d3447 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 7 Sep 2022 09:29:02 -0700 Subject: [PATCH 105/130] fix: pins faceless ui modal --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 555e0b8d5d..aefbd5d09c 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "@babel/preset-typescript": "^7.12.1", "@babel/register": "^7.11.5", "@date-io/date-fns": "^2.10.6", - "@faceless-ui/modal": "^1.1.7", + "@faceless-ui/modal": "~1.2.0", "@faceless-ui/scroll-info": "^1.2.3", "@faceless-ui/window-info": "^2.0.2", "@types/is-plain-object": "^2.0.4", @@ -298,4 +298,4 @@ "publishConfig": { "registry": "https://registry.npmjs.org/" } -} +} \ No newline at end of file From 299ee82ccfb2d0b7933868cfc7869db6eb131f0e Mon Sep 17 00:00:00 2001 From: James Date: Wed, 7 Sep 2022 09:35:24 -0700 Subject: [PATCH 106/130] chore: pins faceless ui --- package.json | 4 ++-- yarn.lock | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index aefbd5d09c..add9f4cc81 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "@babel/preset-typescript": "^7.12.1", "@babel/register": "^7.11.5", "@date-io/date-fns": "^2.10.6", - "@faceless-ui/modal": "~1.2.0", + "@faceless-ui/modal": "1.2.0", "@faceless-ui/scroll-info": "^1.2.3", "@faceless-ui/window-info": "^2.0.2", "@types/is-plain-object": "^2.0.4", @@ -298,4 +298,4 @@ "publishConfig": { "registry": "https://registry.npmjs.org/" } -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index 218e86636f..df0a5ccb8a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1271,7 +1271,7 @@ resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46" integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== -"@faceless-ui/modal@^1.1.7": +"@faceless-ui/modal@1.2.0": version "1.2.0" resolved "https://registry.npmjs.org/@faceless-ui/modal/-/modal-1.2.0.tgz#0ca43e480f83d307dcd84c033fbc82c0619f5d8c" integrity sha512-92LQw1ZIaphzCVaHyhxrzbRtn9LXnm5GOJVXJ4tDUpuz7j1B05QTSOuYWjBd8AZKsBR0MQhgr11BVVgJ70DEhw== From 65653bd1d3cec108561253fba8c7ca79104afa1e Mon Sep 17 00:00:00 2001 From: James Date: Wed, 7 Sep 2022 09:39:24 -0700 Subject: [PATCH 107/130] chore(release): v1.0.34 --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa8641ab97..ace4fa3921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [1.0.34](https://github.com/payloadcms/payload/compare/v1.0.33...v1.0.34) (2022-09-07) + + +### Bug Fixes + +* pins faceless ui modal ([b38b642](https://github.com/payloadcms/payload/commit/b38b6427b8b813487922db0bb7d3762cc41d3447)) + ## [1.0.33](https://github.com/payloadcms/payload/compare/v1.0.30...v1.0.33) (2022-09-07) diff --git a/package.json b/package.json index add9f4cc81..fc4ce49b19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "1.0.33", + "version": "1.0.34", "description": "Node, React and MongoDB Headless CMS and Application Framework", "license": "MIT", "author": { From 13dc39dc6da4cb7c450477f539b09a3cb54ed5af Mon Sep 17 00:00:00 2001 From: James Date: Wed, 7 Sep 2022 11:50:55 -0700 Subject: [PATCH 108/130] fix: #1059 --- src/admin/components/elements/Popup/index.tsx | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/admin/components/elements/Popup/index.tsx b/src/admin/components/elements/Popup/index.tsx index c283206c50..792730487e 100644 --- a/src/admin/components/elements/Popup/index.tsx +++ b/src/admin/components/elements/Popup/index.tsx @@ -1,12 +1,10 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useWindowInfo } from '@faceless-ui/window-info'; -import { useScrollInfo } from '@faceless-ui/scroll-info'; import { Props } from './types'; - -import useThrottledEffect from '../../../hooks/useThrottledEffect'; import PopupButton from './PopupButton'; import './index.scss'; +import useIntersect from '../../../hooks/useIntersect'; const baseClass = 'popup'; @@ -30,26 +28,29 @@ const Popup: React.FC = (props) => { boundingRef, } = props; + const { width: windowWidth, height: windowHeight } = useWindowInfo(); + const [intersectionRef, intersectionEntry] = useIntersect({ + threshold: 1, + rootMargin: '-100px 0px 0px 0px', + root: boundingRef?.current || null, + }); + const buttonRef = useRef(null); const contentRef = useRef(null); - const [mounted, setMounted] = useState(false); const [active, setActive] = useState(initActive); const [verticalAlign, setVerticalAlign] = useState(verticalAlignFromProps); const [horizontalAlign, setHorizontalAlign] = useState(horizontalAlignFromProps); - const { y: scrollY } = useScrollInfo(); - const { height: windowHeight, width: windowWidth } = useWindowInfo(); - const handleClickOutside = useCallback((e) => { if (contentRef.current.contains(e.target)) { return; } setActive(false); - }, []); + }, [contentRef]); - useThrottledEffect(() => { - if (contentRef.current && buttonRef.current) { + useEffect(() => { + if (contentRef.current) { const { left: contentLeftPos, right: contentRightPos, @@ -79,13 +80,11 @@ const Popup: React.FC = (props) => { if (contentTopPos < boundingTopPos && contentBottomPos < boundingBottomPos) { setVerticalAlign('bottom'); - } else if (contentBottomPos > boundingBottomPos && contentTopPos < boundingTopPos) { + } else if (contentBottomPos > boundingBottomPos && contentTopPos > boundingTopPos) { setVerticalAlign('top'); } - - setMounted(true); } - }, 500, [scrollY, windowHeight, windowWidth]); + }, [boundingRef, intersectionEntry, windowHeight, windowWidth]); useEffect(() => { if (typeof onToggleOpen === 'function') onToggleOpen(active); @@ -112,7 +111,7 @@ const Popup: React.FC = (props) => { `${baseClass}--color-${color}`, `${baseClass}--v-align-${verticalAlign}`, `${baseClass}--h-align-${horizontalAlign}`, - (active && mounted) && `${baseClass}--active`, + (active) && `${baseClass}--active`, ].filter(Boolean).join(' '); return ( @@ -144,7 +143,7 @@ const Popup: React.FC = (props) => { >
    Date: Wed, 7 Sep 2022 15:23:26 -0400 Subject: [PATCH 109/130] chore: bumps @faceless-ui/modal to v1.3.2 #1070 --- package.json | 2 +- yarn.lock | 21 +++++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index fc4ce49b19..1b59658d58 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "@babel/preset-typescript": "^7.12.1", "@babel/register": "^7.11.5", "@date-io/date-fns": "^2.10.6", - "@faceless-ui/modal": "1.2.0", + "@faceless-ui/modal": "^1.3.2", "@faceless-ui/scroll-info": "^1.2.3", "@faceless-ui/window-info": "^2.0.2", "@types/is-plain-object": "^2.0.4", diff --git a/yarn.lock b/yarn.lock index df0a5ccb8a..7860924051 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1271,12 +1271,13 @@ resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46" integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== -"@faceless-ui/modal@1.2.0": - version "1.2.0" - resolved "https://registry.npmjs.org/@faceless-ui/modal/-/modal-1.2.0.tgz#0ca43e480f83d307dcd84c033fbc82c0619f5d8c" - integrity sha512-92LQw1ZIaphzCVaHyhxrzbRtn9LXnm5GOJVXJ4tDUpuz7j1B05QTSOuYWjBd8AZKsBR0MQhgr11BVVgJ70DEhw== +"@faceless-ui/modal@^1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@faceless-ui/modal/-/modal-1.3.2.tgz#a085b3b8d18f11ee9a39cfa115453f985e2f9735" + integrity sha512-3KZ/1BLWgAiTrAkRKbMqVW8EEsAX/M6ElpwRZWT1d0usKLvhyUw9o/2SVmkJZp9/a/rP9RGh4xQwgUwDN7TEiA== dependencies: body-scroll-lock "^3.1.5" + focus-trap "^6.9.2" qs "^6.9.1" react-transition-group "^4.4.2" @@ -6013,6 +6014,13 @@ flatted@^2.0.0: resolved "https://registry.npmjs.org/flatted/-/flatted-2.0.2.tgz#4575b21e2bcee7434aa9be662f4b7b5f9c2b5138" integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA== +focus-trap@^6.9.2: + version "6.9.4" + resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-6.9.4.tgz#436da1a1d935c48b97da63cd8f361c6f3aa16444" + integrity sha512-v2NTsZe2FF59Y+sDykKY+XjqZ0cPfhq/hikWVL88BqLivnNiEffAsac6rP6H45ff9wG9LL5ToiDqrLEP9GX9mw== + dependencies: + tabbable "^5.3.3" + follow-redirects@^1.14.0: version "1.15.1" resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5" @@ -11890,6 +11898,11 @@ symbol-tree@^3.2.4: resolved "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== +tabbable@^5.3.3: + version "5.3.3" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.3.3.tgz#aac0ff88c73b22d6c3c5a50b1586310006b47fbf" + integrity sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA== + table@^5.2.3: version "5.4.6" resolved "https://registry.npmjs.org/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e" From eb1ff7efce24315180b5eb58d5cc73afe7eb25f3 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 7 Sep 2022 12:39:56 -0700 Subject: [PATCH 110/130] chore: update label for bug reports --- .github/ISSUE_TEMPLATE/BUG_REPORT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.md b/.github/ISSUE_TEMPLATE/BUG_REPORT.md index 3963f15f79..f82aab2335 100644 --- a/.github/ISSUE_TEMPLATE/BUG_REPORT.md +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.md @@ -1,7 +1,7 @@ --- name: Bug Report about: Create a bug report for Payload -labels: 'bug' +labels: 'possible-bug' --- # Bug Report From cdfc0dec70994edc50a71b1c6d68222c67bde6c2 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 7 Sep 2022 13:48:36 -0700 Subject: [PATCH 111/130] chore: ensures logic is accurate while updating links --- .../RichText/elements/link/index.scss | 3 +- .../RichText/elements/link/index.tsx | 41 +++++++++++-------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/admin/components/forms/field-types/RichText/elements/link/index.scss b/src/admin/components/forms/field-types/RichText/elements/link/index.scss index 2eab975a86..d1272c81e2 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/index.scss +++ b/src/admin/components/forms/field-types/RichText/elements/link/index.scss @@ -15,6 +15,7 @@ &__popup { @extend %body; font-family: var(--font-body); + display: flex; button { @extend %btn-reset; @@ -29,7 +30,7 @@ } &__goto-link { - max-width: base(6); + max-width: base(8); overflow: hidden; text-overflow: ellipsis; margin-right: base(.5); diff --git a/src/admin/components/forms/field-types/RichText/elements/link/index.tsx b/src/admin/components/forms/field-types/RichText/elements/link/index.tsx index e65e3e834a..d7857f43d3 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/index.tsx @@ -56,21 +56,21 @@ const Link = ({ attributes, children, element, editorRef }) => { closeAll(); setRenderModal(false); - // TODO: Inserts duplicate link node - Transforms.removeNodes(editor, { at: editor.selection.focus.path }); - Transforms.insertNodes( + const [, parentPath] = Editor.above(editor); + + Transforms.setNodes( editor, { - type: 'link', newTab: data.newTab, url: data.url, - children: [ - { - text: String(data.text), - }, - ], }, + { at: parentPath }, ); + + Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'block' }); + Transforms.move(editor, { distance: 1, unit: 'offset' }); + Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path }); + ReactEditor.focus(editor); }} initialData={initialData} @@ -86,8 +86,7 @@ const Link = ({ attributes, children, element, editorRef }) => { boundingRef={editorRef} render={() => (
    - Go to link: - {' '} + Go to link:  { type: 'link', url: data.url, newTab: data.newTab, - children: [{ text: String(data.text) }], + children: [], }; if (isCollapsed) { - Transforms.insertNodes(editor, newLink); + // If selection anchor and focus are the same, + // Just inject a new node with children already set + Transforms.insertNodes(editor, { + ...newLink, + children: [{ text: String(data.text) }], + }); } else { - // TODO: Inserts duplicate link node + // Otherwise we need to wrap the selected node in a link, + // Delete its old text, + // Move the selection one position forward into the link, + // And insert the text back into the new link Transforms.wrapNodes(editor, newLink, { split: true }); - Transforms.collapse(editor, { edge: 'end' }); - Transforms.removeNodes(editor, { at: editor.selection.focus.path }); - Transforms.insertNodes(editor, newLink); + Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'word' }); + Transforms.move(editor, { distance: 1, unit: 'offset' }); + Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path }); } closeAll(); From a99d9c98c3f92d6fbeb65c384ca4d43b82184bfd Mon Sep 17 00:00:00 2001 From: James Date: Wed, 7 Sep 2022 16:42:43 -0700 Subject: [PATCH 112/130] feat: allows rich text links to link to other docs --- .../forms/field-types/RichText/RichText.tsx | 2 + .../RichText/elements/link/Button.tsx | 127 +++++++++++ .../RichText/elements/link/Element.tsx | 198 ++++++++++++++++ .../elements/link/Modal/baseFields.ts | 59 +++++ .../RichText/elements/link/Modal/index.tsx | 47 ++-- .../RichText/elements/link/Modal/types.ts | 4 +- .../RichText/elements/link/index.tsx | 214 +----------------- .../upload/Element/EditModal/index.tsx | 6 +- src/fields/config/schema.ts | 3 + src/fields/config/types.ts | 3 + src/fields/hooks/afterRead/promise.ts | 16 +- src/fields/richText/recurseNestedFields.ts | 2 +- ...mise.ts => richTextRelationshipPromise.ts} | 66 ++++-- src/graphql/schema/buildObjectType.ts | 2 +- test/fields/collections/RichText/index.ts | 16 ++ 15 files changed, 492 insertions(+), 273 deletions(-) create mode 100644 src/admin/components/forms/field-types/RichText/elements/link/Button.tsx create mode 100644 src/admin/components/forms/field-types/RichText/elements/link/Element.tsx create mode 100644 src/admin/components/forms/field-types/RichText/elements/link/Modal/baseFields.ts rename src/fields/richText/{relationshipPromise.ts => richTextRelationshipPromise.ts} (62%) diff --git a/src/admin/components/forms/field-types/RichText/RichText.tsx b/src/admin/components/forms/field-types/RichText/RichText.tsx index c30ba2fb1c..cadd9f4beb 100644 --- a/src/admin/components/forms/field-types/RichText/RichText.tsx +++ b/src/admin/components/forms/field-types/RichText/RichText.tsx @@ -234,6 +234,7 @@ const RichText: React.FC = (props) => { if (Button) { return ( + +
    + )} + /> + + { if (e.key === 'Enter') setRenderPopup(true); }} + onClick={() => setRenderPopup(true)} + > + {children} + + + ); +}; diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Modal/baseFields.ts b/src/admin/components/forms/field-types/RichText/elements/link/Modal/baseFields.ts new file mode 100644 index 0000000000..0b0e8a67b0 --- /dev/null +++ b/src/admin/components/forms/field-types/RichText/elements/link/Modal/baseFields.ts @@ -0,0 +1,59 @@ +import { Config } from '../../../../../../../../config/types'; +import { Field } from '../../../../../../../../fields/config/types'; + +export const getBaseFields = (config: Config): Field[] => [ + { + name: 'text', + label: 'Text to display', + type: 'text', + required: true, + }, + { + name: 'linkType', + label: 'Link Type', + type: 'radio', + required: true, + admin: { + description: 'Choose between entering a custom text URL or linking to another document.', + }, + defaultValue: 'custom', + options: [ + { + label: 'Custom URL', + value: 'custom', + }, + { + label: 'Internal Link', + value: 'internal', + }, + ], + }, + { + name: 'url', + label: 'Enter a URL', + type: 'text', + required: true, + admin: { + condition: ({ linkType, url }) => { + return (typeof linkType === 'undefined' && url) || linkType === 'custom'; + }, + }, + }, + { + name: 'doc', + label: 'Choose a document to link to', + type: 'relationship', + required: true, + relationTo: config.collections.map(({ slug }) => slug), + admin: { + condition: ({ linkType }) => { + return linkType === 'internal'; + }, + }, + }, + { + name: 'newTab', + label: 'Open in new tab', + type: 'checkbox', + }, +]; diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.tsx b/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.tsx index af41fb3761..6c59ff650b 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.tsx @@ -1,28 +1,25 @@ import { Modal } from '@faceless-ui/modal'; -import React, { useEffect, useRef } from 'react'; +import React from 'react'; import { MinimalTemplate } from '../../../../../..'; import Button from '../../../../../../elements/Button'; import X from '../../../../../../icons/X'; import Form from '../../../../../Form'; import FormSubmit from '../../../../../Submit'; -import Checkbox from '../../../../Checkbox'; -import Text from '../../../../Text'; import { Props } from './types'; import { modalSlug } from '../shared'; +import fieldTypes from '../../../..'; +import RenderFields from '../../../../../RenderFields'; import './index.scss'; const baseClass = modalSlug; -export const EditModal: React.FC = ({ close, handleModalSubmit, initialData }) => { - const inputRef = useRef(); - - useEffect(() => { - if (inputRef?.current) { - inputRef.current.focus(); - } - }, []); - +export const EditModal: React.FC = ({ + close, + handleModalSubmit, + initialState, + fieldSchema, +}) => { return ( = ({ close, handleModalSubmit, initialDa
    - - - Confirm diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Modal/types.ts b/src/admin/components/forms/field-types/RichText/elements/link/Modal/types.ts index 5e85b35cb0..5582e102ce 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/Modal/types.ts +++ b/src/admin/components/forms/field-types/RichText/elements/link/Modal/types.ts @@ -1,7 +1,9 @@ +import { Field } from '../../../../../../../../fields/config/types'; import { Fields } from '../../../../../Form/types'; export type Props = { close: () => void handleModalSubmit: (fields: Fields, data: Record) => void - initialData?: Record + initialState?: Fields + fieldSchema: Field[] } diff --git a/src/admin/components/forms/field-types/RichText/elements/link/index.tsx b/src/admin/components/forms/field-types/RichText/elements/link/index.tsx index d7857f43d3..63484613d1 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/index.tsx @@ -1,216 +1,10 @@ -import React, { Fragment, useCallback, useEffect, useState } from 'react'; -import { ReactEditor, useSlate } from 'slate-react'; -import { Transforms, Node, Editor, Range } from 'slate'; -import { useModal } from '@faceless-ui/modal'; -import ElementButton from '../Button'; -import { unwrapLink, withLinks } from './utilities'; -import LinkIcon from '../../../../../icons/Link'; -import Popup from '../../../../../elements/Popup'; -import { EditModal } from './Modal'; -import { modalSlug } from './shared'; -import isElementActive from '../isActive'; - -import './index.scss'; - -const baseClass = 'rich-text-link'; - -// TODO: Multiple modal windows stacked go boom (rip). Edit Upload in fields -> rich text - -const Link = ({ attributes, children, element, editorRef }) => { - const editor = useSlate(); - const { open, closeAll } = useModal(); - const [renderModal, setRenderModal] = useState(false); - const [renderPopup, setRenderPopup] = useState(false); - const [initialData, setInitialData] = useState>({}); - - const handleTogglePopup = useCallback((render) => { - if (!render) { - setRenderPopup(render); - } - }, []); - - useEffect(() => { - setInitialData({ - newTab: element.newTab, - text: Node.string(element), - url: element.url, - }); - }, [renderModal, element]); - - return ( - - - {renderModal && ( - { - closeAll(); - setRenderModal(false); - }} - handleModalSubmit={(_, data) => { - closeAll(); - setRenderModal(false); - - const [, parentPath] = Editor.above(editor); - - Transforms.setNodes( - editor, - { - newTab: data.newTab, - url: data.url, - }, - { at: parentPath }, - ); - - Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'block' }); - Transforms.move(editor, { distance: 1, unit: 'offset' }); - Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path }); - - ReactEditor.focus(editor); - }} - initialData={initialData} - /> - )} - ( -
    - Go to link:  - - {element.url} - - — - - -
    - )} - /> -
    - { if (e.key === 'Enter') setRenderPopup(true); }} - onClick={() => setRenderPopup(true)} - > - {children} - -
    - ); -}; - -const LinkButton = () => { - const editor = useSlate(); - const { open, closeAll } = useModal(); - const [renderModal, setRenderModal] = useState(false); - const [initialData, setInitialData] = useState>({}); - - return ( - - { - if (isElementActive(editor, 'link')) { - unwrapLink(editor); - } else { - open(modalSlug); - setRenderModal(true); - - const isCollapsed = editor.selection && Range.isCollapsed(editor.selection); - - if (!isCollapsed) { - setInitialData({ - text: Editor.string(editor, editor.selection), - }); - } - } - }} - > - - - {renderModal && ( - { - closeAll(); - setRenderModal(false); - }} - handleModalSubmit={(_, data) => { - const isCollapsed = editor.selection && Range.isCollapsed(editor.selection); - - const newLink = { - type: 'link', - url: data.url, - newTab: data.newTab, - children: [], - }; - - if (isCollapsed) { - // If selection anchor and focus are the same, - // Just inject a new node with children already set - Transforms.insertNodes(editor, { - ...newLink, - children: [{ text: String(data.text) }], - }); - } else { - // Otherwise we need to wrap the selected node in a link, - // Delete its old text, - // Move the selection one position forward into the link, - // And insert the text back into the new link - Transforms.wrapNodes(editor, newLink, { split: true }); - Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'word' }); - Transforms.move(editor, { distance: 1, unit: 'offset' }); - Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path }); - } - - closeAll(); - setRenderModal(false); - - ReactEditor.focus(editor); - }} - /> - )} - - ); -}; +import { withLinks } from './utilities'; +import { LinkButton } from './Button'; +import { LinkElement } from './Element'; const link = { Button: LinkButton, - Element: Link, + Element: LinkElement, plugins: [ withLinks, ], diff --git a/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx b/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx index 71e61656c8..433e1300be 100644 --- a/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx @@ -10,7 +10,6 @@ import Button from '../../../../../../../elements/Button'; import RenderFields from '../../../../../../RenderFields'; import fieldTypes from '../../../../..'; import Form from '../../../../../../Form'; -import reduceFieldsToValues from '../../../../../../Form/reduceFieldsToValues'; import Submit from '../../../../../../Submit'; import { Field } from '../../../../../../../../../fields/config/types'; import { useLocale } from '../../../../../../../utilities/Locale'; @@ -34,9 +33,9 @@ export const EditModal: React.FC = ({ slug, closeModal, relatedCollection const { user } = useAuth(); const locale = useLocale(); - const handleUpdateEditData = useCallback((fields) => { + const handleUpdateEditData = useCallback((_, data) => { const newNode = { - fields: reduceFieldsToValues(fields, true), + fields: data, }; const elementPath = ReactEditor.findPath(editor, element); @@ -90,7 +89,6 @@ export const EditModal: React.FC = ({ slug, closeModal, relatedCollection fieldTypes={fieldTypes} fieldSchema={fieldSchema} /> - Save changes diff --git a/src/fields/config/schema.ts b/src/fields/config/schema.ts index ada1bdfc55..a1f0b36035 100644 --- a/src/fields/config/schema.ts +++ b/src/fields/config/schema.ts @@ -346,6 +346,9 @@ export const richText = baseField.keys({ fields: joi.array().items(joi.link('#field')), })), }), + link: joi.object({ + fields: joi.array().items(joi.link('#field')), + }), }), }); diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index 824c50eb9b..63e410f3a4 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -293,6 +293,9 @@ export type RichTextField = FieldBase & { } } } + link?: { + fields?: Field[]; + } } } diff --git a/src/fields/hooks/afterRead/promise.ts b/src/fields/hooks/afterRead/promise.ts index dea6bceaed..95c7ff4c57 100644 --- a/src/fields/hooks/afterRead/promise.ts +++ b/src/fields/hooks/afterRead/promise.ts @@ -2,7 +2,7 @@ import { Field, fieldAffectsData } from '../../config/types'; import { PayloadRequest } from '../../../express/types'; import { traverseFields } from './traverseFields'; -import richTextRelationshipPromise from '../../richText/relationshipPromise'; +import richTextRelationshipPromise from '../../richText/richTextRelationshipPromise'; import relationshipPopulationPromise from './relationshipPopulationPromise'; type Args = { @@ -47,11 +47,11 @@ export const promise = async ({ } const hasLocalizedValue = flattenLocales - && fieldAffectsData(field) - && (typeof siblingDoc[field.name] === 'object' && siblingDoc[field.name] !== null) - && field.name - && field.localized - && req.locale !== 'all'; + && fieldAffectsData(field) + && (typeof siblingDoc[field.name] === 'object' && siblingDoc[field.name] !== null) + && field.name + && field.localized + && req.locale !== 'all'; if (hasLocalizedValue) { let localizedValue = siblingDoc[field.name][req.locale]; @@ -110,8 +110,8 @@ export const promise = async ({ await priorHook; const shouldRunHookOnAllLocales = field.localized - && (req.locale === 'all' || !flattenLocales) - && typeof siblingDoc[field.name] === 'object'; + && (req.locale === 'all' || !flattenLocales) + && typeof siblingDoc[field.name] === 'object'; if (shouldRunHookOnAllLocales) { const hookPromises = Object.entries(siblingDoc[field.name]).map(([locale, value]) => (async () => { diff --git a/src/fields/richText/recurseNestedFields.ts b/src/fields/richText/recurseNestedFields.ts index ce884fc6f9..eaac69edfc 100644 --- a/src/fields/richText/recurseNestedFields.ts +++ b/src/fields/richText/recurseNestedFields.ts @@ -2,7 +2,7 @@ import { Field, fieldHasSubFields, fieldIsArrayType, fieldAffectsData } from '../config/types'; import { PayloadRequest } from '../../express/types'; import { populate } from './populate'; -import { recurseRichText } from './relationshipPromise'; +import { recurseRichText } from './richTextRelationshipPromise'; type NestedRichTextFieldsArgs = { promises: Promise[] diff --git a/src/fields/richText/relationshipPromise.ts b/src/fields/richText/richTextRelationshipPromise.ts similarity index 62% rename from src/fields/richText/relationshipPromise.ts rename to src/fields/richText/richTextRelationshipPromise.ts index f6a4bfbc0d..e97f474dd9 100644 --- a/src/fields/richText/relationshipPromise.ts +++ b/src/fields/richText/richTextRelationshipPromise.ts @@ -36,12 +36,26 @@ export const recurseRichText = ({ }: RecurseRichTextArgs): void => { if (Array.isArray(children)) { (children as any[]).forEach((element) => { - const collection = req.payload.collections[element?.relationTo]; - if ((element.type === 'relationship' || element.type === 'upload') && element?.value?.id - && collection && (depth && currentDepth <= depth)) { + const collection = req.payload.collections[element?.relationTo]; + + if (collection) { + promises.push(populate({ + req, + id: element.value.id, + data: element, + key: 'value', + overrideAccess, + depth, + currentDepth, + field, + collection, + showHiddenFields, + })); + } + if (element.type === 'upload' && Array.isArray(field.admin?.upload?.collections?.[element?.relationTo]?.fields)) { recurseNestedFields({ promises, @@ -54,18 +68,40 @@ export const recurseRichText = ({ showHiddenFields, }); } - promises.push(populate({ - req, - id: element.value.id, - data: element, - key: 'value', - overrideAccess, - depth, - currentDepth, - field, - collection, - showHiddenFields, - })); + } + + if (element.type === 'link') { + if (element?.doc?.value && element?.doc?.relationTo) { + const collection = req.payload.collections[element?.doc?.relationTo]; + + if (collection) { + promises.push(populate({ + req, + id: element.doc.value, + data: element.doc, + key: 'value', + overrideAccess, + depth, + currentDepth, + field, + collection, + showHiddenFields, + })); + } + } + + if (Array.isArray(field.admin?.link?.fields)) { + recurseNestedFields({ + promises, + data: element.fields || {}, + fields: field.admin?.link?.fields, + req, + overrideAccess, + depth, + currentDepth, + showHiddenFields, + }); + } } if (element?.children) { diff --git a/src/graphql/schema/buildObjectType.ts b/src/graphql/schema/buildObjectType.ts index 40cfa5eb15..84aa312292 100644 --- a/src/graphql/schema/buildObjectType.ts +++ b/src/graphql/schema/buildObjectType.ts @@ -42,7 +42,7 @@ import formatName from '../utilities/formatName'; import combineParentName from '../utilities/combineParentName'; import withNullableType from './withNullableType'; import { toWords } from '../../utilities/formatLabels'; -import createRichTextRelationshipPromise from '../../fields/richText/relationshipPromise'; +import createRichTextRelationshipPromise from '../../fields/richText/richTextRelationshipPromise'; import formatOptions from '../utilities/formatOptions'; import { Payload } from '../..'; import buildWhereInputType from './buildWhereInputType'; diff --git a/test/fields/collections/RichText/index.ts b/test/fields/collections/RichText/index.ts index 538a4c1a34..88585ec301 100644 --- a/test/fields/collections/RichText/index.ts +++ b/test/fields/collections/RichText/index.ts @@ -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: { From eb963066f7238fa1ec596e82cd515686efd94e01 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 7 Sep 2022 16:52:32 -0700 Subject: [PATCH 113/130] chore: fixes bug with links always populating --- .../RichText/elements/link/Element.tsx | 9 +- .../RichText/elements/link/index.scss | 2 +- .../richText/richTextRelationshipPromise.ts | 101 +++++++++--------- 3 files changed, 58 insertions(+), 54 deletions(-) diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx b/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx index 6770e7e2d7..3bfc85a3b4 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx @@ -141,16 +141,19 @@ export const LinkElement = ({ attributes, children, element, editorRef, fieldPro boundingRef={editorRef} render={() => (
    - {element.linkType === 'internal' && ( + {element.linkType === 'internal' && element.doc?.relationTo && element.doc?.value && ( - Linked to doc  + Linked to  + + {config.collections.find(({ slug }) => slug === element.doc.relationTo)?.labels?.singular} + )} {(element.linkType === 'custom' || !element.linkType) && ( Go to link:  { if (Array.isArray(children)) { (children as any[]).forEach((element) => { - if ((element.type === 'relationship' || element.type === 'upload') - && element?.value?.id - && (depth && currentDepth <= depth)) { - const collection = req.payload.collections[element?.relationTo]; - - if (collection) { - promises.push(populate({ - req, - id: element.value.id, - data: element, - key: 'value', - overrideAccess, - depth, - currentDepth, - field, - collection, - showHiddenFields, - })); - } - - if (element.type === 'upload' && Array.isArray(field.admin?.upload?.collections?.[element?.relationTo]?.fields)) { - recurseNestedFields({ - promises, - data: element.fields || {}, - fields: field.admin.upload.collections[element.relationTo].fields, - req, - overrideAccess, - depth, - currentDepth, - showHiddenFields, - }); - } - } - - if (element.type === 'link') { - if (element?.doc?.value && element?.doc?.relationTo) { - const collection = req.payload.collections[element?.doc?.relationTo]; + if ((depth && currentDepth <= depth)) { + if ((element.type === 'relationship' || element.type === 'upload') + && element?.value?.id) { + const collection = req.payload.collections[element?.relationTo]; if (collection) { promises.push(populate({ req, - id: element.doc.value, - data: element.doc, + id: element.value.id, + data: element, key: 'value', overrideAccess, depth, @@ -88,19 +55,53 @@ export const recurseRichText = ({ showHiddenFields, })); } + + if (element.type === 'upload' && Array.isArray(field.admin?.upload?.collections?.[element?.relationTo]?.fields)) { + recurseNestedFields({ + promises, + data: element.fields || {}, + fields: field.admin.upload.collections[element.relationTo].fields, + req, + overrideAccess, + depth, + currentDepth, + showHiddenFields, + }); + } } - if (Array.isArray(field.admin?.link?.fields)) { - recurseNestedFields({ - promises, - data: element.fields || {}, - fields: field.admin?.link?.fields, - req, - overrideAccess, - depth, - currentDepth, - showHiddenFields, - }); + if (element.type === 'link') { + if (element?.doc?.value && element?.doc?.relationTo) { + const collection = req.payload.collections[element?.doc?.relationTo]; + + if (collection) { + promises.push(populate({ + req, + id: element.doc.value, + data: element.doc, + key: 'value', + overrideAccess, + depth, + currentDepth, + field, + collection, + showHiddenFields, + })); + } + } + + if (Array.isArray(field.admin?.link?.fields)) { + recurseNestedFields({ + promises, + data: element.fields || {}, + fields: field.admin?.link?.fields, + req, + overrideAccess, + depth, + currentDepth, + showHiddenFields, + }); + } } } From a45577f18286488c96dd921b00c34b5cfbc3051d Mon Sep 17 00:00:00 2001 From: James Date: Wed, 7 Sep 2022 17:25:15 -0700 Subject: [PATCH 114/130] chore: misc ux improvements to rich text link --- .../RichText/elements/link/Button.tsx | 4 +- .../RichText/elements/link/Element.tsx | 54 +++++++++++-------- .../RichText/elements/link/index.scss | 18 ++++++- 3 files changed, 50 insertions(+), 26 deletions(-) diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Button.tsx b/src/admin/components/forms/field-types/RichText/elements/link/Button.tsx index d3e12d19ae..ca6c2f1d2c 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/Button.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/Button.tsx @@ -15,6 +15,7 @@ import { useLocale } from '../../../../../utilities/Locale'; import { useConfig } from '../../../../../utilities/Config'; import { getBaseFields } from './Modal/baseFields'; import { Field } from '../../../../../../../fields/config/types'; +import reduceFieldsToValues from '../../../../Form/reduceFieldsToValues'; export const LinkButton = ({ fieldProps }) => { const customFieldSchema = fieldProps?.admin?.link?.fields; @@ -84,8 +85,9 @@ export const LinkButton = ({ fieldProps }) => { closeAll(); setRenderModal(false); }} - handleModalSubmit={(_, data) => { + handleModalSubmit={(fields) => { const isCollapsed = editor.selection && Range.isCollapsed(editor.selection); + const data = reduceFieldsToValues(fields, true); const newLink = { type: 'link', diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx b/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx index 3bfc85a3b4..6f1b9776fa 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx @@ -15,8 +15,11 @@ import { getBaseFields } from './Modal/baseFields'; import { Field } from '../../../../../../../fields/config/types'; import reduceFieldsToValues from '../../../../Form/reduceFieldsToValues'; import deepCopyObject from '../../../../../../../utilities/deepCopyObject'; +import Edit from '../../../../../icons/Edit'; +import X from '../../../../../icons/X'; import './index.scss'; +import Button from '../../../../../elements/Button'; const baseClass = 'rich-text-link'; @@ -144,43 +147,48 @@ export const LinkElement = ({ attributes, children, element, editorRef, fieldPro {element.linkType === 'internal' && element.doc?.relationTo && element.doc?.value && ( Linked to  - - {config.collections.find(({ slug }) => slug === element.doc.relationTo)?.labels?.singular} - - - )} - {(element.linkType === 'custom' || !element.linkType) && ( - - Go to link:  - {element.url} + {config.collections.find(({ slug }) => slug === element.doc.relationTo)?.labels?.singular} )} - — - - + tooltip="Remove" + />
    )} /> diff --git a/src/admin/components/forms/field-types/RichText/elements/link/index.scss b/src/admin/components/forms/field-types/RichText/elements/link/index.scss index 93f1045339..a0ae348aba 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/index.scss +++ b/src/admin/components/forms/field-types/RichText/elements/link/index.scss @@ -10,6 +10,20 @@ right: 0; bottom: 0; left: 0; + + + .popup__scroll, + .popup__wrap { + overflow: visible; + } + + .popup__scroll { + padding-right: base(.5); + } + } + + .icon--x line { + stroke-width: 2px; } &__popup { @@ -19,9 +33,9 @@ button { @extend %btn-reset; - margin-left: base(.5); font-weight: 600; cursor: pointer; + margin: 0 0 0 base(.25); &:hover { text-decoration: underline; @@ -33,7 +47,7 @@ max-width: base(8); overflow: hidden; text-overflow: ellipsis; - margin-right: base(.5); + margin-right: base(.25); } } From 3c46851426a8b1d6c7637a477bbfbc8bb6355698 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 8 Sep 2022 15:22:09 -0700 Subject: [PATCH 115/130] chore: migrates to new faceless modal version --- package.json | 2 +- .../elements/DeleteDocument/index.tsx | 8 +++---- .../elements/GenerateConfirmation/index.tsx | 4 +++- .../components/elements/Status/index.tsx | 18 ++++++++++------ .../RichText/elements/link/Button.tsx | 13 +++++++----- .../RichText/elements/link/Element.tsx | 9 ++++---- .../RichText/elements/link/Modal/index.scss | 2 +- .../RichText/elements/link/Modal/index.tsx | 4 ++-- .../RichText/elements/link/Modal/types.ts | 1 + .../elements/relationship/Button/index.tsx | 12 +++++------ .../RichText/elements/upload/Button/index.tsx | 12 +++++------ .../elements/upload/Element/index.tsx | 12 +++++------ .../forms/field-types/Upload/Add/index.tsx | 8 +++---- .../Upload/SelectExisting/index.tsx | 8 +++---- .../components/modals/StayLoggedIn/index.tsx | 8 ++++--- yarn.lock | 21 +++++++++++++++---- 16 files changed, 85 insertions(+), 57 deletions(-) diff --git a/package.json b/package.json index fc4ce49b19..8111a8f331 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "@babel/preset-typescript": "^7.12.1", "@babel/register": "^7.11.5", "@date-io/date-fns": "^2.10.6", - "@faceless-ui/modal": "1.2.0", + "@faceless-ui/modal": "2.0.0-alpha.1", "@faceless-ui/scroll-info": "^1.2.3", "@faceless-ui/window-info": "^2.0.2", "@types/is-plain-object": "^2.0.4", diff --git a/src/admin/components/elements/DeleteDocument/index.tsx b/src/admin/components/elements/DeleteDocument/index.tsx index f9873eb9c5..48740ec94f 100644 --- a/src/admin/components/elements/DeleteDocument/index.tsx +++ b/src/admin/components/elements/DeleteDocument/index.tsx @@ -33,7 +33,7 @@ const DeleteDocument: React.FC = (props) => { const { serverURL, routes: { api, admin } } = useConfig(); const { setModified } = useForm(); const [deleting, setDeleting] = useState(false); - const { closeAll, toggle } = useModal(); + const { toggle } = useModal(); const history = useHistory(); const title = useTitle(useAsTitle) || id; const titleToRender = titleFromProps || title; @@ -55,12 +55,12 @@ const DeleteDocument: React.FC = (props) => { try { const json = await res.json(); if (res.status < 400) { - closeAll(); + toggle(modalSlug); toast.success(`${singular} "${title}" successfully deleted.`); return history.push(`${admin}/collections/${slug}`); } - closeAll(); + toggle(modalSlug); if (json.errors) { json.errors.forEach((error) => toast.error(error.message)); @@ -72,7 +72,7 @@ const DeleteDocument: React.FC = (props) => { return addDefaultError(); } }); - }, [addDefaultError, closeAll, history, id, singular, slug, title, admin, api, serverURL, setModified]); + }, [addDefaultError, toggle, modalSlug, history, id, singular, slug, title, admin, api, serverURL, setModified]); if (id) { return ( diff --git a/src/admin/components/elements/GenerateConfirmation/index.tsx b/src/admin/components/elements/GenerateConfirmation/index.tsx index 2237e781d5..fe8eca1aaf 100644 --- a/src/admin/components/elements/GenerateConfirmation/index.tsx +++ b/src/admin/components/elements/GenerateConfirmation/index.tsx @@ -4,6 +4,7 @@ import { Modal, useModal } from '@faceless-ui/modal'; import Button from '../Button'; import MinimalTemplate from '../../templates/Minimal'; import { Props } from './types'; +import { useDocumentInfo } from '../../utilities/DocumentInfo'; import './index.scss'; @@ -15,9 +16,10 @@ const GenerateConfirmation: React.FC = (props) => { highlightField, } = props; + const { id } = useDocumentInfo(); const { toggle } = useModal(); - const modalSlug = 'generate-confirmation'; + const modalSlug = `generate-confirmation-${id}`; const handleGenerate = () => { setKey(); diff --git a/src/admin/components/elements/Status/index.tsx b/src/admin/components/elements/Status/index.tsx index 1ea54765b3..a330698899 100644 --- a/src/admin/components/elements/Status/index.tsx +++ b/src/admin/components/elements/Status/index.tsx @@ -15,17 +15,17 @@ import './index.scss'; const baseClass = 'status'; -const unPublishModalSlug = 'confirm-un-publish'; -const revertModalSlug = 'confirm-revert'; - const Status: React.FC = () => { const { publishedDoc, unpublishedVersions, collection, global, id, getVersions } = useDocumentInfo(); - const { toggle, closeAll: closeAllModals } = useModal(); + const { toggle } = useModal(); const { serverURL, routes: { api } } = useConfig(); const [processing, setProcessing] = useState(false); const { reset: resetForm } = useForm(); const locale = useLocale(); + const unPublishModalSlug = `confirm-un-publish-${id}`; + const revertModalSlug = `confirm-revert-${id}`; + let statusToRender; if (unpublishedVersions?.docs?.length > 0 && publishedDoc) { @@ -92,8 +92,14 @@ const Status: React.FC = () => { } setProcessing(false); - closeAllModals(); - }, [closeAllModals, collection, global, serverURL, api, resetForm, id, locale, getVersions, publishedDoc]); + if (action === 'revert') { + toggle(revertModalSlug); + } + + if (action === 'unpublish') { + toggle(unPublishModalSlug); + } + }, [collection, global, publishedDoc, serverURL, api, id, locale, resetForm, getVersions, toggle, revertModalSlug, unPublishModalSlug]); if (statusToRender) { return ( diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Button.tsx b/src/admin/components/forms/field-types/RichText/elements/link/Button.tsx index ca6c2f1d2c..60bcc944cc 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/Button.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/Button.tsx @@ -6,7 +6,7 @@ import ElementButton from '../Button'; import { unwrapLink } from './utilities'; import LinkIcon from '../../../../../icons/Link'; import { EditModal } from './Modal'; -import { modalSlug } from './shared'; +import { modalSlug as baseModalSlug } from './shared'; import isElementActive from '../isActive'; import { Fields } from '../../../../Form/types'; import buildStateFromSchema from '../../../../Form/buildStateFromSchema'; @@ -20,11 +20,13 @@ import reduceFieldsToValues from '../../../../Form/reduceFieldsToValues'; export const LinkButton = ({ fieldProps }) => { const customFieldSchema = fieldProps?.admin?.link?.fields; + const modalSlug = `${baseModalSlug}-${fieldProps.path}`; + const config = useConfig(); const editor = useSlate(); const { user } = useAuth(); const locale = useLocale(); - const { open, closeAll } = useModal(); + const { toggle } = useModal(); const [renderModal, setRenderModal] = useState(false); const [initialState, setInitialState] = useState({}); const [fieldSchema] = useState(() => { @@ -59,7 +61,7 @@ export const LinkButton = ({ fieldProps }) => { if (isElementActive(editor, 'link')) { unwrapLink(editor); } else { - open(modalSlug); + toggle(modalSlug); setRenderModal(true); const isCollapsed = editor.selection && Range.isCollapsed(editor.selection); @@ -79,10 +81,11 @@ export const LinkButton = ({ fieldProps }) => { {renderModal && ( { - closeAll(); + toggle(modalSlug); setRenderModal(false); }} handleModalSubmit={(fields) => { @@ -117,7 +120,7 @@ export const LinkButton = ({ fieldProps }) => { Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path }); } - closeAll(); + toggle(modalSlug); setRenderModal(false); ReactEditor.focus(editor); diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx b/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx index 6f1b9776fa..78a85a0953 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx @@ -5,7 +5,7 @@ import { useModal } from '@faceless-ui/modal'; import { unwrapLink } from './utilities'; import Popup from '../../../../../elements/Popup'; import { EditModal } from './Modal'; -import { modalSlug } from './shared'; +import { modalSlug as baseModalSlug } from './shared'; import { Fields } from '../../../../Form/types'; import buildStateFromSchema from '../../../../Form/buildStateFromSchema'; import { useAuth } from '../../../../../utilities/Auth'; @@ -15,11 +15,9 @@ import { getBaseFields } from './Modal/baseFields'; import { Field } from '../../../../../../../fields/config/types'; import reduceFieldsToValues from '../../../../Form/reduceFieldsToValues'; import deepCopyObject from '../../../../../../../utilities/deepCopyObject'; -import Edit from '../../../../../icons/Edit'; -import X from '../../../../../icons/X'; +import Button from '../../../../../elements/Button'; import './index.scss'; -import Button from '../../../../../elements/Button'; const baseClass = 'rich-text-link'; @@ -60,6 +58,8 @@ export const LinkElement = ({ attributes, children, element, editorRef, fieldPro return fields; }); + const modalSlug = `${baseModalSlug}-${fieldProps.path}`; + const handleTogglePopup = useCallback((render) => { if (!render) { setRenderPopup(render); @@ -95,6 +95,7 @@ export const LinkElement = ({ attributes, children, element, editorRef, fieldPro > {renderModal && ( { closeAll(); diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.scss b/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.scss index 4d9eca42f4..8d2e1d4c9f 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.scss +++ b/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.scss @@ -1,6 +1,6 @@ @import '../../../../../../../scss/styles.scss'; -.rich-text-link-modal { +.rich-text-link-edit-modal { @include blur-bg; display: flex; align-items: center; diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.tsx b/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.tsx index 6c59ff650b..f311c32cc2 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.tsx @@ -6,19 +6,19 @@ import X from '../../../../../../icons/X'; import Form from '../../../../../Form'; import FormSubmit from '../../../../../Submit'; import { Props } from './types'; -import { modalSlug } from '../shared'; import fieldTypes from '../../../..'; import RenderFields from '../../../../../RenderFields'; import './index.scss'; -const baseClass = modalSlug; +const baseClass = 'rich-text-link-edit-modal'; export const EditModal: React.FC = ({ close, handleModalSubmit, initialState, fieldSchema, + modalSlug, }) => { return ( void handleModalSubmit: (fields: Fields, data: Record) => void initialState?: Fields diff --git a/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.tsx b/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.tsx index 1eb550cb09..f79a27194d 100644 --- a/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.tsx @@ -37,7 +37,7 @@ const insertRelationship = (editor, { value, relationTo }) => { }; const RelationshipButton: React.FC<{ path: string }> = ({ path }) => { - const { open, closeAll } = useModal(); + const { toggle } = useModal(); const editor = useSlate(); const { serverURL, routes: { api }, collections } = useConfig(); const [renderModal, setRenderModal] = useState(false); @@ -52,16 +52,16 @@ const RelationshipButton: React.FC<{ path: string }> = ({ path }) => { const json = await res.json(); insertRelationship(editor, { value: { id: json.id }, relationTo }); - closeAll(); + toggle(modalSlug); setRenderModal(false); setLoading(false); - }, [editor, closeAll, api, serverURL]); + }, [editor, toggle, modalSlug, api, serverURL]); useEffect(() => { if (renderModal) { - open(modalSlug); + toggle(modalSlug); } - }, [renderModal, open, modalSlug]); + }, [renderModal, toggle, modalSlug]); if (!hasEnabledCollections) return null; @@ -85,7 +85,7 @@ const RelationshipButton: React.FC<{ path: string }> = ({ path }) => {
    {description && ( @@ -140,7 +140,7 @@ const SelectExistingUploadModal: React.FC = (props) => { collection={collection} onCardClick={(doc) => { setValue(doc); - closeAll(); + toggle(modalSlug); }} />
    diff --git a/src/admin/components/modals/StayLoggedIn/index.tsx b/src/admin/components/modals/StayLoggedIn/index.tsx index fef878b6fa..1eafea0846 100644 --- a/src/admin/components/modals/StayLoggedIn/index.tsx +++ b/src/admin/components/modals/StayLoggedIn/index.tsx @@ -10,11 +10,13 @@ import './index.scss'; const baseClass = 'stay-logged-in'; +const modalSlug = 'stay-logged-in'; + const StayLoggedInModal: React.FC = (props) => { const { refreshCookie } = props; const history = useHistory(); const { routes: { admin } } = useConfig(); - const { closeAll: closeAllModals } = useModal(); + const { toggle } = useModal(); return ( = (props) => { diff --git a/src/admin/components/elements/DuplicateDocument/index.tsx b/src/admin/components/elements/DuplicateDocument/index.tsx index ba48cc4cdd..b1cc135135 100644 --- a/src/admin/components/elements/DuplicateDocument/index.tsx +++ b/src/admin/components/elements/DuplicateDocument/index.tsx @@ -16,7 +16,7 @@ const baseClass = 'duplicate'; const Duplicate: React.FC = ({ slug, collection, id }) => { const { push } = useHistory(); const modified = useFormModified(); - const { toggle } = useModal(); + const { toggleModal } = useModal(); const { setModified } = useForm(); const { serverURL, routes: { api }, localization } = useConfig(); const { routes: { admin } } = useConfig(); @@ -28,7 +28,7 @@ const Duplicate: React.FC = ({ slug, collection, id }) => { setHasClicked(true); if (modified && !override) { - toggle(modalSlug); + toggleModal(modalSlug); return; } @@ -92,7 +92,7 @@ const Duplicate: React.FC = ({ slug, collection, id }) => { pathname: `${admin}/collections/${slug}/${duplicateID}`, }); }, 10); - }, [modified, localization, collection.labels.singular, setModified, toggle, modalSlug, serverURL, api, slug, id, push, admin]); + }, [modified, localization, collection.labels.singular, setModified, toggleModal, modalSlug, serverURL, api, slug, id, push, admin]); const confirm = useCallback(async () => { setHasClicked(false); @@ -123,7 +123,7 @@ const Duplicate: React.FC = ({ slug, collection, id }) => { id="confirm-cancel" buttonStyle="secondary" type="button" - onClick={() => toggle(modalSlug)} + onClick={() => toggleModal(modalSlug)} > Cancel diff --git a/src/admin/components/elements/GenerateConfirmation/index.tsx b/src/admin/components/elements/GenerateConfirmation/index.tsx index fe8eca1aaf..2b3797c37d 100644 --- a/src/admin/components/elements/GenerateConfirmation/index.tsx +++ b/src/admin/components/elements/GenerateConfirmation/index.tsx @@ -17,13 +17,13 @@ const GenerateConfirmation: React.FC = (props) => { } = props; const { id } = useDocumentInfo(); - const { toggle } = useModal(); + const { toggleModal } = useModal(); const modalSlug = `generate-confirmation-${id}`; const handleGenerate = () => { setKey(); - toggle(modalSlug); + toggleModal(modalSlug); toast.success('New API Key Generated.', { autoClose: 3000 }); highlightField(true); }; @@ -34,7 +34,7 @@ const GenerateConfirmation: React.FC = (props) => { size="small" buttonStyle="secondary" onClick={() => { - toggle(modalSlug); + toggleModal(modalSlug); }} > Generate new API key @@ -59,7 +59,7 @@ const GenerateConfirmation: React.FC = (props) => { buttonStyle="secondary" type="button" onClick={() => { - toggle(modalSlug); + toggleModal(modalSlug); }} > Cancel diff --git a/src/admin/components/elements/Status/index.tsx b/src/admin/components/elements/Status/index.tsx index a330698899..2a435a3226 100644 --- a/src/admin/components/elements/Status/index.tsx +++ b/src/admin/components/elements/Status/index.tsx @@ -17,7 +17,7 @@ const baseClass = 'status'; const Status: React.FC = () => { const { publishedDoc, unpublishedVersions, collection, global, id, getVersions } = useDocumentInfo(); - const { toggle } = useModal(); + const { toggleModal } = useModal(); const { serverURL, routes: { api } } = useConfig(); const [processing, setProcessing] = useState(false); const { reset: resetForm } = useForm(); @@ -93,13 +93,13 @@ const Status: React.FC = () => { setProcessing(false); if (action === 'revert') { - toggle(revertModalSlug); + toggleModal(revertModalSlug); } if (action === 'unpublish') { - toggle(unPublishModalSlug); + toggleModal(unPublishModalSlug); } - }, [collection, global, publishedDoc, serverURL, api, id, locale, resetForm, getVersions, toggle, revertModalSlug, unPublishModalSlug]); + }, [collection, global, publishedDoc, serverURL, api, id, locale, resetForm, getVersions, toggleModal, revertModalSlug, unPublishModalSlug]); if (statusToRender) { return ( @@ -110,7 +110,7 @@ const Status: React.FC = () => {  —  @@ -143,7 +143,7 @@ const Status: React.FC = () => {  —  diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Button.tsx b/src/admin/components/forms/field-types/RichText/elements/link/Button.tsx index 60bcc944cc..b9843f70ec 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/Button.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/Button.tsx @@ -26,7 +26,7 @@ export const LinkButton = ({ fieldProps }) => { const editor = useSlate(); const { user } = useAuth(); const locale = useLocale(); - const { toggle } = useModal(); + const { toggleModal } = useModal(); const [renderModal, setRenderModal] = useState(false); const [initialState, setInitialState] = useState({}); const [fieldSchema] = useState(() => { @@ -61,7 +61,7 @@ export const LinkButton = ({ fieldProps }) => { if (isElementActive(editor, 'link')) { unwrapLink(editor); } else { - toggle(modalSlug); + toggleModal(modalSlug); setRenderModal(true); const isCollapsed = editor.selection && Range.isCollapsed(editor.selection); @@ -85,7 +85,7 @@ export const LinkButton = ({ fieldProps }) => { fieldSchema={fieldSchema} initialState={initialState} close={() => { - toggle(modalSlug); + toggleModal(modalSlug); setRenderModal(false); }} handleModalSubmit={(fields) => { @@ -120,7 +120,7 @@ export const LinkButton = ({ fieldProps }) => { Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path }); } - toggle(modalSlug); + toggleModal(modalSlug); setRenderModal(false); ReactEditor.focus(editor); diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx b/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx index 78a85a0953..32cd9e90ef 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx @@ -30,7 +30,7 @@ export const LinkElement = ({ attributes, children, element, editorRef, fieldPro const config = useConfig(); const { user } = useAuth(); const locale = useLocale(); - const { open, closeAll } = useModal(); + const { openModal, toggleModal } = useModal(); const [renderModal, setRenderModal] = useState(false); const [renderPopup, setRenderPopup] = useState(false); const [initialState, setInitialState] = useState({}); @@ -98,11 +98,11 @@ export const LinkElement = ({ attributes, children, element, editorRef, fieldPro modalSlug={modalSlug} fieldSchema={fieldSchema} close={() => { - closeAll(); + toggleModal(modalSlug); setRenderModal(false); }} handleModalSubmit={(fields) => { - closeAll(); + toggleModal(modalSlug); setRenderModal(false); const data = reduceFieldsToValues(fields, true); @@ -175,7 +175,7 @@ export const LinkElement = ({ attributes, children, element, editorRef, fieldPro onClick={(e) => { e.preventDefault(); setRenderPopup(false); - open(modalSlug); + openModal(modalSlug); setRenderModal(true); }} tooltip="Edit" diff --git a/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.tsx b/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.tsx index f79a27194d..59d470f71f 100644 --- a/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.tsx @@ -37,7 +37,7 @@ const insertRelationship = (editor, { value, relationTo }) => { }; const RelationshipButton: React.FC<{ path: string }> = ({ path }) => { - const { toggle } = useModal(); + const { toggleModal } = useModal(); const editor = useSlate(); const { serverURL, routes: { api }, collections } = useConfig(); const [renderModal, setRenderModal] = useState(false); @@ -52,16 +52,16 @@ const RelationshipButton: React.FC<{ path: string }> = ({ path }) => { const json = await res.json(); insertRelationship(editor, { value: { id: json.id }, relationTo }); - toggle(modalSlug); + toggleModal(modalSlug); setRenderModal(false); setLoading(false); - }, [editor, toggle, modalSlug, api, serverURL]); + }, [editor, toggleModal, modalSlug, api, serverURL]); useEffect(() => { if (renderModal) { - toggle(modalSlug); + toggleModal(modalSlug); } - }, [renderModal, toggle, modalSlug]); + }, [renderModal, toggleModal, modalSlug]); if (!hasEnabledCollections) return null; @@ -85,7 +85,7 @@ const RelationshipButton: React.FC<{ path: string }> = ({ path }) => {
    {description && ( @@ -140,7 +140,7 @@ const SelectExistingUploadModal: React.FC = (props) => { collection={collection} onCardClick={(doc) => { setValue(doc); - toggle(modalSlug); + toggleModal(modalSlug); }} />
    diff --git a/src/admin/components/modals/StayLoggedIn/index.tsx b/src/admin/components/modals/StayLoggedIn/index.tsx index 1eafea0846..3cb06a08fe 100644 --- a/src/admin/components/modals/StayLoggedIn/index.tsx +++ b/src/admin/components/modals/StayLoggedIn/index.tsx @@ -16,7 +16,7 @@ const StayLoggedInModal: React.FC = (props) => { const { refreshCookie } = props; const history = useHistory(); const { routes: { admin } } = useConfig(); - const { toggle } = useModal(); + const { toggleModal } = useModal(); return ( = (props) => { diff --git a/yarn.lock b/yarn.lock index 6e573b4302..3da3c695fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1271,10 +1271,10 @@ resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46" integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== -"@faceless-ui/modal@2.0.0-alpha.1": - version "2.0.0-alpha.1" - resolved "https://registry.npmjs.org/@faceless-ui/modal/-/modal-2.0.0-alpha.1.tgz#ecf1f760edd46959de3389650c3742dacb8cdb53" - integrity sha512-moASA7cR4DTZg72f3652iZa/VBHBB92JObA8MTca4GefLg+3xBDMCk8X9qn5Prb5B/M6YMhm/wZ22SNK3a3PDg== +"@faceless-ui/modal@2.0.0-alpha.2": + version "2.0.0-alpha.2" + resolved "https://registry.npmjs.org/@faceless-ui/modal/-/modal-2.0.0-alpha.2.tgz#3f8c493904ccfa9a8ad163550792312d86507e34" + integrity sha512-gBoaSyL2CSzkOpnKo9pFL3vs22P6x4JG6wgo34LQ3HjxtyV3ea/Y6EjinzUzRLsPyJ7jBU4GGBMilKUCLUkL6w== dependencies: body-scroll-lock "^3.1.5" focus-trap "^6.9.2" From 50cc3c5a21ae5aa6a96e19d0f6836ab10b4e2db2 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Thu, 8 Sep 2022 23:08:54 -0400 Subject: [PATCH 120/130] chore: bumps @faceless-ui/modal to v2.0.0-alpha.3 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 924df32bdc..764a732711 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "@babel/preset-typescript": "^7.12.1", "@babel/register": "^7.11.5", "@date-io/date-fns": "^2.10.6", - "@faceless-ui/modal": "2.0.0-alpha.2", + "@faceless-ui/modal": "^2.0.0-alpha.3", "@faceless-ui/scroll-info": "^1.2.3", "@faceless-ui/window-info": "^2.0.2", "@types/is-plain-object": "^2.0.4", diff --git a/yarn.lock b/yarn.lock index 3da3c695fa..8e0cef3aad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1271,10 +1271,10 @@ resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46" integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== -"@faceless-ui/modal@2.0.0-alpha.2": - version "2.0.0-alpha.2" - resolved "https://registry.npmjs.org/@faceless-ui/modal/-/modal-2.0.0-alpha.2.tgz#3f8c493904ccfa9a8ad163550792312d86507e34" - integrity sha512-gBoaSyL2CSzkOpnKo9pFL3vs22P6x4JG6wgo34LQ3HjxtyV3ea/Y6EjinzUzRLsPyJ7jBU4GGBMilKUCLUkL6w== +"@faceless-ui/modal@^2.0.0-alpha.3": + version "2.0.0-alpha.3" + resolved "https://registry.yarnpkg.com/@faceless-ui/modal/-/modal-2.0.0-alpha.3.tgz#4cc378e2f7d033197ff22bdc6882f54ed22ccb37" + integrity sha512-+5r3DrnNK4g5vL2ZDRYW+kkaSbGYGTJ3UsK1vXdvB1krlVt3Nu4KOe0lq3FAEtq4g/lryNmgL6/1ffgFOoz9VQ== dependencies: body-scroll-lock "^3.1.5" focus-trap "^6.9.2" From 48f0c06edc6b88cd810cad585d887c25bf375d49 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 9 Sep 2022 00:13:27 -0400 Subject: [PATCH 121/130] chore: bumps @faceless-ui/modal to v2.0.0-alpha.4 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 764a732711..323365d210 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "@babel/preset-typescript": "^7.12.1", "@babel/register": "^7.11.5", "@date-io/date-fns": "^2.10.6", - "@faceless-ui/modal": "^2.0.0-alpha.3", + "@faceless-ui/modal": "^2.0.0-alpha.4", "@faceless-ui/scroll-info": "^1.2.3", "@faceless-ui/window-info": "^2.0.2", "@types/is-plain-object": "^2.0.4", diff --git a/yarn.lock b/yarn.lock index 8e0cef3aad..0c70a7a250 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1271,10 +1271,10 @@ resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46" integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== -"@faceless-ui/modal@^2.0.0-alpha.3": - version "2.0.0-alpha.3" - resolved "https://registry.yarnpkg.com/@faceless-ui/modal/-/modal-2.0.0-alpha.3.tgz#4cc378e2f7d033197ff22bdc6882f54ed22ccb37" - integrity sha512-+5r3DrnNK4g5vL2ZDRYW+kkaSbGYGTJ3UsK1vXdvB1krlVt3Nu4KOe0lq3FAEtq4g/lryNmgL6/1ffgFOoz9VQ== +"@faceless-ui/modal@^2.0.0-alpha.4": + version "2.0.0-alpha.4" + resolved "https://registry.yarnpkg.com/@faceless-ui/modal/-/modal-2.0.0-alpha.4.tgz#f47c373433f186dc4b7e85c3e310562db3420eaa" + integrity sha512-v2b+vPhswX7ZBVQXdziUr89qst2ZdshLDQE8No/9LeGnQAo1TmNw1zPDuCBXF6Xi0gmHO6yUyfMFwFzUPcZl3Q== dependencies: body-scroll-lock "^3.1.5" focus-trap "^6.9.2" From 5a19f6915a17dbb072b89f63f32705d5f0fc75ce Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Fri, 9 Sep 2022 15:17:51 -0700 Subject: [PATCH 122/130] fix: rich text link with no selection --- .../forms/field-types/RichText/elements/link/Button.tsx | 6 +++--- .../forms/field-types/RichText/elements/link/Element.tsx | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Button.tsx b/src/admin/components/forms/field-types/RichText/elements/link/Button.tsx index b9843f70ec..89f9dc542c 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/Button.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/Button.tsx @@ -68,7 +68,7 @@ export const LinkButton = ({ fieldProps }) => { if (!isCollapsed) { const data = { - text: Editor.string(editor, editor.selection), + text: editor.selection ? Editor.string(editor, editor.selection) : '', }; const state = await buildStateFromSchema({ fieldSchema, data, user, operation: 'create', locale }); @@ -102,14 +102,14 @@ export const LinkButton = ({ fieldProps }) => { children: [], }; - if (isCollapsed) { + if (isCollapsed || !editor.selection) { // If selection anchor and focus are the same, // Just inject a new node with children already set Transforms.insertNodes(editor, { ...newLink, children: [{ text: String(data.text) }], }); - } else { + } else if (editor.selection) { // Otherwise we need to wrap the selected node in a link, // Delete its old text, // Move the selection one position forward into the link, diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx b/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx index 32cd9e90ef..32fd00a69e 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx @@ -169,6 +169,7 @@ export const LinkElement = ({ attributes, children, element, editorRef, fieldPro )}