diff --git a/demo/collections/Geolocation.ts b/demo/collections/Geolocation.ts new file mode 100644 index 0000000000..e9adffa5f1 --- /dev/null +++ b/demo/collections/Geolocation.ts @@ -0,0 +1,81 @@ +/* eslint-disable no-param-reassign */ +import { CollectionConfig } from '../../src/collections/config/types'; + +const validateFieldTransformAction = (hook: string, value) => { + if (value !== undefined && value !== null && !Array.isArray(value)) { + console.error(hook, value); + throw new Error('Field transformAction should convert value to array [x, y] and not { coordinates: [x, y] }'); + } + return value; +}; + +const Geolocation: CollectionConfig = { + slug: 'geolocation', + labels: { + singular: 'Geolocation', + plural: 'Geolocations', + }, + access: { + read: () => true, + }, + hooks: { + beforeRead: [ + (operation) => operation.doc, + ], + beforeChange: [ + (operation) => { + // eslint-disable-next-line no-param-reassign,operator-assignment + operation.data.beforeChange = !operation.data.location?.coordinates; + return operation.data; + }, + ], + afterRead: [ + (operation) => { + const { doc } = operation; + doc.afterReadHook = !doc.location?.coordinates; + return doc; + }, + ], + afterChange: [ + (operation) => { + const { doc } = operation; + doc.afterChangeHook = !doc.location?.coordinates; + return doc; + }, + ], + afterDelete: [ + (operation) => { + const { doc } = operation; + operation.doc.afterDeleteHook = !doc.location?.coordinates; + return doc; + }, + ], + }, + fields: [ + { + name: 'location', + type: 'point', + label: 'Location', + hooks: { + beforeValidate: [({ value }) => validateFieldTransformAction('beforeValidate', value)], + beforeChange: [({ value }) => validateFieldTransformAction('beforeChange', value)], + afterChange: [({ value }) => validateFieldTransformAction('afterChange', value)], + afterRead: [({ value }) => validateFieldTransformAction('afterRead', value)], + }, + }, + { + name: 'localizedPoint', + type: 'point', + label: 'Localized Point', + localized: true, + hooks: { + beforeValidate: [({ value }) => validateFieldTransformAction('beforeValidate', value)], + beforeChange: [({ value }) => validateFieldTransformAction('beforeChange', value)], + afterChange: [({ value }) => validateFieldTransformAction('afterChange', value)], + afterRead: [({ value }) => validateFieldTransformAction('afterRead', value)], + }, + }, + ], +}; + +export default Geolocation; diff --git a/demo/collections/Localized.ts b/demo/collections/Localized.ts index f222bb2a30..2b0f173d94 100644 --- a/demo/collections/Localized.ts +++ b/demo/collections/Localized.ts @@ -1,6 +1,15 @@ import { CollectionConfig } from '../../src/collections/config/types'; +import { PayloadRequest } from '../../src/express/types'; import { Block } from '../../src/fields/config/types'; +const validateLocalizationTransform = (hook: string, value, req: PayloadRequest) => { + if (req.locale !== 'all' && value !== undefined && typeof value !== 'string') { + console.error(hook, value); + throw new Error('Locale transformation should happen before hook is called'); + } + return value; +}; + const RichTextBlock: Block = { slug: 'richTextBlock', labels: { @@ -46,6 +55,12 @@ const LocalizedPosts: CollectionConfig = { required: true, unique: true, localized: true, + hooks: { + beforeValidate: [({ value, req }) => validateLocalizationTransform('beforeValidate', value, req)], + beforeChange: [({ value, req }) => validateLocalizationTransform('beforeChange', value, req)], + afterChange: [({ value, req }) => validateLocalizationTransform('afterChange', value, req)], + afterRead: [({ value, req }) => validateLocalizationTransform('afterRead', value, req)], + }, }, { name: 'summary', diff --git a/demo/payload.config.ts b/demo/payload.config.ts index b621c7ed21..237c102c9e 100644 --- a/demo/payload.config.ts +++ b/demo/payload.config.ts @@ -26,6 +26,7 @@ import Select from './collections/Select'; import StrictPolicies from './collections/StrictPolicies'; import Validations from './collections/Validations'; import Uniques from './collections/Uniques'; +import Geolocation from './collections/Geolocation'; import BlocksGlobal from './globals/BlocksGlobal'; import NavigationArray from './globals/NavigationArray'; @@ -83,6 +84,7 @@ export default buildConfig({ Validations, Uniques, UnstoredMedia, + Geolocation, ], globals: [ NavigationArray, @@ -108,7 +110,7 @@ export default buildConfig({ defaultDepth: 2, graphQL: { maxComplexity: 1000, - disablePlaygroundInProduction: true, + disablePlaygroundInProduction: false, disable: false, }, // rateLimit: { diff --git a/docs/fields/overview.mdx b/docs/fields/overview.mdx index 58027874c2..e275e63d1a 100644 --- a/docs/fields/overview.mdx +++ b/docs/fields/overview.mdx @@ -41,6 +41,7 @@ const Pages = { - [Email](/docs/fields/email) - validates the entry is a properly formatted email - [Group](/docs/fields/group) - nest fields within an object - [Number](/docs/fields/number) - field that enforces that its value be a number +- [Point](/docs/fields/point) - geometric coordinates for location data - [Radio](/docs/fields/radio) - radio button group, allowing only one value to be selected - [Relationship](/docs/fields/relationship) - assign relationships to other collections - [Rich Text](/docs/fields/rich-text) - fully extensible Rich Text editor diff --git a/docs/fields/point.mdx b/docs/fields/point.mdx new file mode 100644 index 0000000000..14555020a5 --- /dev/null +++ b/docs/fields/point.mdx @@ -0,0 +1,54 @@ +--- +title: Point Field +label: Point +order: 95 +desc: The Point field type stores coordinates in the database. Learn how to use Point field for geolocation and geometry. + +keywords: point, geolocation, geospatial, geojson, 2dsphere, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, express +--- + + + The Point field type saves a pair of coordinates in the database and assigns an index for location related queries. + + +The data structure in the database matches the GeoJSON structure to represent point. The Payload APIs simplifies the object data to only the [x, y] location. + +### Config + +| Option | Description | +| ---------------- | ----------- | +| **`name`** * | To be used as the property name when stored and retrieved from the database. | +| **`label`** | Used as a field label in the Admin panel and to name the generated GraphQL type. | +| **`unique`** | Enforce that each entry in the Collection has a unique value for this field. | +| **`index`** | Build a [MongoDB index](https://docs.mongodb.com/manual/indexes/) for this field to produce faster queries. To support location queries, point index defaults to `2dsphere`, to disable the index set to `false`. | +| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) | +| **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/config), include its data in the user JWT. | +| **`hooks`** | Provide field-based hooks to control logic for this field. [More](/docs/fields/overview#field-level-hooks) | +| **`access`** | Provide field-based access control to denote what users can see and do with this field's data. [More](/docs/fields/overview#field-level-access-control) | +| **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin panel. | +| **`defaultValue`** | Provide data to be used for this field's default value. | +| **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | +| **`required`** | Require this field to have a value. | +| **`admin`** | Admin-specific configuration. See below for [more detail](#admin-config). | + +*\* An asterisk denotes that a property is required.* + +### Example + +`collections/ExampleCollection.js` +```js +{ + slug: 'example-collection', + fields: [ + { + name: 'location', + type: 'point', + label: 'Location', + }, + ] +} +``` + +### Querying + +In order to do query based on the distance to another point, you can use the `near` operator. When querying using the near operator, the returned documents will be sorted by nearest first. diff --git a/docs/queries/overview.mdx b/docs/queries/overview.mdx index a4fddbca74..7545a47525 100644 --- a/docs/queries/overview.mdx +++ b/docs/queries/overview.mdx @@ -63,6 +63,7 @@ The above example demonstrates a simple query but you can get much more complex. | `in` | The value must be found within the provided comma-delimited list of values. | | `not_in` | The value must NOT be within the provided comma-delimited list of values. | | `exists` | Only return documents where the value either exists (`true`) or does not exist (`false`). | +| `near` | For distance related to a [point field]('/docs/fields/point') comma separated as `, , , `. | Tip:
diff --git a/src/admin/components/elements/WhereBuilder/field-types.tsx b/src/admin/components/elements/WhereBuilder/field-types.tsx index 1cdf649d8e..6839f7829e 100644 --- a/src/admin/components/elements/WhereBuilder/field-types.tsx +++ b/src/admin/components/elements/WhereBuilder/field-types.tsx @@ -45,6 +45,18 @@ const numeric = [ }, ]; +const geo = [ + ...boolean, + { + label: 'exists', + value: 'exists', + }, + { + label: 'near', + value: 'near', + }, +]; + const like = { label: 'is like', value: 'like', @@ -79,6 +91,10 @@ const fieldTypeConditions = { component: 'Date', operators: [...base, ...numeric], }, + point: { + component: 'Point', + operators: [...geo], + }, upload: { component: 'Text', operators: [...base], diff --git a/src/admin/components/forms/field-types/Point/index.scss b/src/admin/components/forms/field-types/Point/index.scss new file mode 100644 index 0000000000..aa6cd53533 --- /dev/null +++ b/src/admin/components/forms/field-types/Point/index.scss @@ -0,0 +1,30 @@ +@import '../../../../scss/styles.scss'; + +.point { + position: relative; + margin-bottom: $baseline; + + &__wrap { + display: flex; + width: calc(100% + #{base(1)}); + margin-left: base(-.5); + margin-right: base(-.5); + list-style: none; + padding: 0; + + li { + padding: 0 base(.5); + width: 50%; + } + } + + input { + @include formInput; + } + + &.error { + input { + background-color: lighten($color-red, 20%); + } + } +} diff --git a/src/admin/components/forms/field-types/Point/index.tsx b/src/admin/components/forms/field-types/Point/index.tsx new file mode 100644 index 0000000000..62f0cc1759 --- /dev/null +++ b/src/admin/components/forms/field-types/Point/index.tsx @@ -0,0 +1,124 @@ +import React, { useCallback } from 'react'; +import useFieldType from '../../useFieldType'; +import Label from '../../Label'; +import Error from '../../Error'; +import FieldDescription from '../../FieldDescription'; +import withCondition from '../../withCondition'; +import { point } from '../../../../../fields/validations'; +import { Props } from './types'; + +import './index.scss'; + +const baseClass = 'point'; + +const PointField: React.FC = (props) => { + const { + name, + path: pathFromProps, + required, + validate = point, + label, + admin: { + readOnly, + style, + width, + step, + placeholder, + description, + condition, + } = {}, + } = props; + + const path = pathFromProps || name; + + const memoizedValidate = useCallback((value) => { + const validationResult = validate(value, { required }); + return validationResult; + }, [validate, required]); + + const { + value = [null, null], + showError, + setValue, + errorMessage, + } = useFieldType<[number, number]>({ + path, + validate: memoizedValidate, + enableDebouncedValue: true, + condition, + }); + + const handleChange = useCallback((e, index: 0 | 1) => { + let val = parseFloat(e.target.value); + if (Number.isNaN(val)) { + val = e.target.value; + } + const coordinates = [...value]; + coordinates[index] = val; + setValue(coordinates); + }, [setValue, value]); + + const classes = [ + 'field-type', + baseClass, + showError && 'error', + readOnly && 'read-only', + ].filter(Boolean).join(' '); + + return ( +
+ +
    +
  • +
  • +
  • +
  • +
+ +
+ ); +}; + +export default withCondition(PointField); diff --git a/src/admin/components/forms/field-types/Point/types.ts b/src/admin/components/forms/field-types/Point/types.ts new file mode 100644 index 0000000000..d7a3d32371 --- /dev/null +++ b/src/admin/components/forms/field-types/Point/types.ts @@ -0,0 +1,5 @@ +import { NumberField } from '../../../../../fields/config/types'; + +export type Props = Omit & { + path?: string +} diff --git a/src/admin/components/forms/field-types/index.tsx b/src/admin/components/forms/field-types/index.tsx index 91ddad3e1c..30e9b9d851 100644 --- a/src/admin/components/forms/field-types/index.tsx +++ b/src/admin/components/forms/field-types/index.tsx @@ -13,6 +13,7 @@ import number from './Number'; import checkbox from './Checkbox'; import richText from './RichText'; import radio from './RadioGroup'; +import point from './Point'; import blocks from './Blocks'; import group from './Group'; @@ -32,6 +33,7 @@ export type FieldTypes = { textarea: React.ComponentType select: React.ComponentType number: React.ComponentType + point: React.ComponentType checkbox: React.ComponentType richText: React.ComponentType radio: React.ComponentType @@ -56,6 +58,7 @@ const fieldTypes: FieldTypes = { number, checkbox, richText, + point, radio, blocks, group, diff --git a/src/collections/bindCollection.ts b/src/collections/bindCollection.ts index 5499a95c72..2242b5fa4c 100644 --- a/src/collections/bindCollection.ts +++ b/src/collections/bindCollection.ts @@ -1,8 +1,8 @@ -const bindCollectionMiddleware = (collection) => { - return (req, res, next) => { - req.collection = collection; - next(); - }; +import { NextFunction, Request, Response } from 'express'; + +const bindCollectionMiddleware = (collection: string) => (req: Request & { collection: string }, res: Response, next: NextFunction) => { + req.collection = collection; + next(); }; export default bindCollectionMiddleware; diff --git a/src/collections/buildSchema.ts b/src/collections/buildSchema.ts index 6a3be5edee..df5d3c0541 100644 --- a/src/collections/buildSchema.ts +++ b/src/collections/buildSchema.ts @@ -1,11 +1,18 @@ import paginate from 'mongoose-paginate-v2'; +import { Schema } from 'mongoose'; +import { SanitizedConfig } from '../config/types'; import buildQueryPlugin from '../mongoose/buildQuery'; import buildSchema from '../mongoose/buildSchema'; +import { SanitizedCollectionConfig } from './config/types'; -const buildCollectionSchema = (collection, config, schemaOptions = {}) => { - const schema = buildSchema(config, collection.fields, { timestamps: collection.timestamps !== false, ...schemaOptions }); +const buildCollectionSchema = (collection: SanitizedCollectionConfig, config: SanitizedConfig, schemaOptions = {}): Schema => { + const schema = buildSchema( + config, + collection.fields, + { timestamps: collection.timestamps !== false, ...schemaOptions }, + ); - schema.plugin(paginate) + schema.plugin(paginate, { useEstimatedCount: true }) .plugin(buildQueryPlugin); return schema; diff --git a/src/collections/init.ts b/src/collections/init.ts index 82df0ee87e..b792fb75fe 100644 --- a/src/collections/init.ts +++ b/src/collections/init.ts @@ -8,6 +8,7 @@ import apiKeyStrategy from '../auth/strategies/apiKey'; import buildSchema from './buildSchema'; import bindCollectionMiddleware from './bindCollection'; import { SanitizedCollectionConfig } from './config/types'; +import { SanitizedConfig } from '../config/types'; import { Payload } from '../index'; const LocalStrategy = Passport.Strategy; @@ -16,7 +17,7 @@ export default function registerCollections(ctx: Payload): void { ctx.config.collections = ctx.config.collections.map((collection: SanitizedCollectionConfig) => { const formattedCollection = collection; - const schema = buildSchema(formattedCollection, ctx.config); + const schema = buildSchema(formattedCollection, ctx.config as SanitizedConfig); if (collection.auth) { schema.plugin(passportLocalMongoose, { diff --git a/src/collections/operations/create.ts b/src/collections/operations/create.ts index 2e3533adb4..0361cc140f 100644 --- a/src/collections/operations/create.ts +++ b/src/collections/operations/create.ts @@ -222,34 +222,6 @@ async function create(this: Payload, incomingArgs: Arguments): Promise result = JSON.parse(result); result = sanitizeInternalFields(result); - // ///////////////////////////////////// - // afterChange - Fields - // ///////////////////////////////////// - - result = await this.performFieldOperations(collectionConfig, { - data: result, - hook: 'afterChange', - operation: 'create', - req, - depth, - overrideAccess, - showHiddenFields, - }); - - // ///////////////////////////////////// - // afterChange - Collection - // ///////////////////////////////////// - - await collectionConfig.hooks.afterChange.reduce(async (priorHook: AfterChangeHook | Promise, hook: AfterChangeHook) => { - await priorHook; - - result = await hook({ - doc: result, - req: args.req, - operation: 'create', - }) || result; - }, Promise.resolve()); - // ///////////////////////////////////// // Send verification email if applicable // ///////////////////////////////////// @@ -295,6 +267,34 @@ async function create(this: Payload, incomingArgs: Arguments): Promise }) || result; }, Promise.resolve()); + // ///////////////////////////////////// + // afterChange - Fields + // ///////////////////////////////////// + + result = await this.performFieldOperations(collectionConfig, { + data: result, + hook: 'afterChange', + operation: 'create', + req, + depth, + overrideAccess, + showHiddenFields, + }); + + // ///////////////////////////////////// + // afterChange - Collection + // ///////////////////////////////////// + + await collectionConfig.hooks.afterChange.reduce(async (priorHook: AfterChangeHook | Promise, hook: AfterChangeHook) => { + await priorHook; + + result = await hook({ + doc: result, + req: args.req, + operation: 'create', + }) || result; + }, Promise.resolve()); + // ///////////////////////////////////// // Return results // ///////////////////////////////////// diff --git a/src/collections/operations/find.ts b/src/collections/operations/find.ts index 53242c4f6e..8eeab70501 100644 --- a/src/collections/operations/find.ts +++ b/src/collections/operations/find.ts @@ -108,6 +108,7 @@ async function find(incomingArgs: Arguments): Promise { sort, lean: true, leanWithId: true, + useEstimatedCount: true, }; const paginatedDocs = await Model.paginate(query, optionsToExecute); diff --git a/src/collections/tests/collections.spec.js b/src/collections/tests/collections.spec.js index b8e27a505d..736d515a7c 100644 --- a/src/collections/tests/collections.spec.js +++ b/src/collections/tests/collections.spec.js @@ -406,7 +406,6 @@ describe('Collections - REST', () => { expect(data.docs.length).toBeGreaterThan(0); expect(data.totalDocs).toBeGreaterThan(0); expect(data.limit).toBe(10); - expect(data.totalPages).toBe(2); expect(data.page).toBe(1); expect(data.pagingCounter).toBe(1); expect(data.hasPrevPage).toBe(false); diff --git a/src/collections/tests/pointField.spec.js b/src/collections/tests/pointField.spec.js new file mode 100644 index 0000000000..a0d412baf7 --- /dev/null +++ b/src/collections/tests/pointField.spec.js @@ -0,0 +1,171 @@ +import { GraphQLClient } from 'graphql-request'; +import getConfig from '../../config/load'; +import { email, password } from '../../mongoose/testCredentials'; + +require('isomorphic-fetch'); + +const { serverURL, routes } = getConfig(); + +let token = null; +let headers = null; + +describe('GeoJSON', () => { + beforeAll(async (done) => { + const response = await fetch(`${serverURL}/api/admins/login`, { + body: JSON.stringify({ + email, + password, + }), + headers: { + 'Content-Type': 'application/json', + }, + method: 'post', + }); + + const data = await response.json(); + + ({ token } = data); + headers = { + Authorization: `JWT ${token}`, + 'Content-Type': 'application/json', + }; + + done(); + }); + + describe('Point Field - REST', () => { + const location = [10, 20]; + const localizedPoint = [30, 40]; + let doc; + + beforeAll(async (done) => { + const create = await fetch(`${serverURL}/api/geolocation`, { + body: JSON.stringify({ location, localizedPoint }), + headers, + method: 'post', + }); + ({ doc } = await create.json()); + + done(); + }); + + it('should create and read collections with points', async () => { + expect(doc.id).not.toBeNull(); + expect(doc.location).toStrictEqual(location); + expect(doc.localizedPoint).toStrictEqual(localizedPoint); + }); + + it('should query where near point', async () => { + const [x, y] = location; + const hitResponse = await fetch(`${serverURL}/api/geolocation?where[location][near]=${x + 0.01},${y + 0.01},10000`, { + headers, + method: 'get', + }); + const hitData = await hitResponse.json(); + const hitDocs = hitData.docs; + + const missResponse = await fetch(`${serverURL}/api/geolocation?where[location][near]=-${x},-${y},5000`, { + headers, + method: 'get', + }); + const missData = await missResponse.json(); + const missDocs = missData.docs; + + expect(hitDocs).toHaveLength(1); + expect(missDocs).toHaveLength(0); + }); + + it('should query where near localized point', async () => { + const [x, y] = localizedPoint; + const hitResponse = await fetch(`${serverURL}/api/geolocation?where[localizedPoint][near]=${x + 0.01},${y + 0.01},10000`, { + headers, + method: 'get', + }); + const hitData = await hitResponse.json(); + const hitDocs = hitData.docs; + + const missResponse = await fetch(`${serverURL}/api/geolocation?where[localizedPoint][near]=-${x},-${y},5000`, { + headers, + method: 'get', + }); + const missData = await missResponse.json(); + const missDocs = missData.docs; + + expect(hitDocs).toHaveLength(1); + expect(missDocs).toHaveLength(0); + }); + }); + + describe('Point Field - GraphQL', () => { + const url = `${serverURL}${routes.api}${routes.graphQL}`; + let client = null; + const location = [50, 60]; + const localizedPoint = [70, 80]; + let doc; + + beforeAll(async (done) => { + client = new GraphQLClient(url, { headers: { Authorization: `JWT ${token}` } }); + + // language=graphQL + const query = `mutation { + createGeolocation (data: {location: [${location[0]}, ${location[1]}], localizedPoint: [${localizedPoint[0]}, ${localizedPoint[1]}]}) { + id + location + localizedPoint + } + }`; + + const response = await client.request(query); + + const { id } = response.createGeolocation; + // language=graphQL + const readQuery = `query { + Geolocation(id: "${id}") { + id + location + localizedPoint + } + }`; + const readResponse = await client.request(readQuery); + doc = readResponse.Geolocation; + done(); + }); + + it('should create and read collections with points', async () => { + expect(doc.id).not.toBeNull(); + expect(doc.location).toStrictEqual(location); + expect(doc.localizedPoint).toStrictEqual(localizedPoint); + }); + + it('should query where near point', async () => { + const [x, y] = location; + // language=graphQL + const hitQuery = `query getGeos { + Geolocations(where: { location: { near: [${x + 0.01},${y + 0.01},10000]}}) { + docs { + id + location + localizedPoint + } + } + }`; + const hitResponse = await client.request(hitQuery); + const hitDocs = hitResponse.Geolocations.docs; + + const missQuery = `query getGeos { + Geolocations(where: { location: { near: [${-x},${-y},10000]}}) { + docs { + id + location + localizedPoint + } + } + }`; + const missResponse = await client.request(missQuery); + const missDocs = missResponse.Geolocations.docs; + + expect(hitDocs).toHaveLength(1); + expect(missDocs).toHaveLength(0); + }); + }); +}); diff --git a/src/fields/config/schema.ts b/src/fields/config/schema.ts index d245bc0746..20f90f6446 100644 --- a/src/fields/config/schema.ts +++ b/src/fields/config/schema.ts @@ -185,6 +185,12 @@ export const checkbox = baseField.keys({ defaultValue: joi.boolean(), }); +export const point = baseField.keys({ + type: joi.string().valid('point').required(), + name: joi.string().required(), + defaultValue: joi.array().items(joi.number()).max(2).min(2), +}); + export const relationship = baseField.keys({ type: joi.string().valid('relationship').required(), hasMany: joi.boolean().default(false), @@ -290,6 +296,7 @@ const fieldSchema = joi.alternatives() richText, blocks, date, + point, ) .id('field'); diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index 5e47ac41b7..65f342a332 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -243,6 +243,10 @@ export type BlockField = FieldBase & { labels?: Labels } +export type PointField = FieldBase & { + type: 'point', +} + export type Field = TextField | NumberField @@ -259,6 +263,7 @@ export type Field = | SelectField | UploadField | CodeField + | PointField | RowField; export type FieldWithPath = Field & { diff --git a/src/fields/performFieldOperations.ts b/src/fields/performFieldOperations.ts index 2775f8963c..02f4b25cf3 100644 --- a/src/fields/performFieldOperations.ts +++ b/src/fields/performFieldOperations.ts @@ -66,6 +66,7 @@ export default async function performFieldOperations(this: Payload, entityConfig const relationshipPopulations = []; const hookPromises = []; const unflattenLocaleActions = []; + const transformActions = []; const errors: { message: string, field: string }[] = []; // ////////////////////////////////////////// @@ -98,26 +99,35 @@ export default async function performFieldOperations(this: Payload, entityConfig showHiddenFields, unflattenLocales, unflattenLocaleActions, + transformActions, docWithLocales, }); - await Promise.all(hookPromises); + if (hook === 'afterRead') { + transformActions.forEach((action) => action()); + } + + const hookResults = hookPromises.map((promise) => promise()); + await Promise.all(hookResults); validationPromises.forEach((promise) => promise()); - await Promise.all(validationPromises); if (errors.length > 0) { throw new ValidationError(errors); } + if (hook === 'beforeChange') { + transformActions.forEach((action) => action()); + } + unflattenLocaleActions.forEach((action) => action()); - await Promise.all(accessPromises); + const accessResults = accessPromises.map((promise) => promise()); + await Promise.all(accessResults); - const relationshipPopulationPromises = relationshipPopulations.map((population) => population()); - - await Promise.all(relationshipPopulationPromises); + const relationshipPopulationResults = relationshipPopulations.map((population) => population()); + await Promise.all(relationshipPopulationResults); return fullData; } diff --git a/src/fields/traverseFields.ts b/src/fields/traverseFields.ts index 680afab4c9..8364d48eb9 100644 --- a/src/fields/traverseFields.ts +++ b/src/fields/traverseFields.ts @@ -15,7 +15,7 @@ type Arguments = { flattenLocales: boolean locale: string fallbackLocale: string - accessPromises: Promise[] + accessPromises: (() => Promise)[] operation: Operation overrideAccess: boolean req: PayloadRequest @@ -24,7 +24,7 @@ type Arguments = { depth: number currentDepth: number hook: HookName - hookPromises: Promise[] + hookPromises: (() => Promise)[] fullOriginalDoc: Record fullData: Record validationPromises: (() => Promise)[] @@ -33,6 +33,7 @@ type Arguments = { showHiddenFields: boolean unflattenLocales: boolean unflattenLocaleActions: (() => void)[] + transformActions: (() => void)[] docWithLocales?: Record skipValidation?: boolean } @@ -64,6 +65,7 @@ const traverseFields = (args: Arguments): void => { showHiddenFields, unflattenLocaleActions, unflattenLocales, + transformActions, docWithLocales = {}, skipValidation, } = args; @@ -75,6 +77,14 @@ const traverseFields = (args: Arguments): void => { delete data[field.name]; } + if (hook === 'afterRead' && field.type === 'point') { + transformActions.push(() => { + if (data[field.name]?.coordinates && Array.isArray(data[field.name].coordinates) && data[field.name].coordinates.length === 2) { + data[field.name] = data[field.name].coordinates; + } + }); + } + if ((field.type === 'upload' || field.type === 'relationship') && (data[field.name] === '' || data[field.name] === 'none' || data[field.name] === 'null')) { dataCopy[field.name] = null; @@ -163,7 +173,7 @@ const traverseFields = (args: Arguments): void => { }); } - accessPromises.push(accessPromise({ + accessPromises.push(() => accessPromise({ data, fullData, originalDoc, @@ -179,7 +189,7 @@ const traverseFields = (args: Arguments): void => { payload, })); - hookPromises.push(hookPromise({ + hookPromises.push(() => hookPromise({ data, field, hook, @@ -257,6 +267,20 @@ const traverseFields = (args: Arguments): void => { updatedData[field.name] = field.defaultValue; } + if (field.type === 'point' && data[field.name]) { + transformActions.push(() => { + if (Array.isArray(data[field.name]) && data[field.name][0] !== null && data[field.name][1] !== null) { + data[field.name] = { + type: 'Point', + coordinates: [ + parseFloat(data[field.name][0]), + parseFloat(data[field.name][1]), + ], + }; + } + }); + } + if (field.type === 'array' || field.type === 'blocks') { const hasRowsOfNewData = Array.isArray(data[field.name]); const newRowCount = hasRowsOfNewData ? (data[field.name] as Record[]).length : 0; diff --git a/src/fields/validations.ts b/src/fields/validations.ts index b16ae44d4b..9e58aa7bfd 100644 --- a/src/fields/validations.ts +++ b/src/fields/validations.ts @@ -199,6 +199,24 @@ export const blocks: Validate = (value, options = {}) => { return true; }; +export const point: Validate = (value: [number | string, number | string] = ['', ''], options = {}) => { + const x = parseFloat(String(value[0])); + const y = parseFloat(String(value[1])); + if ( + (value[0] && value[1] && typeof x !== 'number' && typeof y !== 'number') + || (options.required && (Number.isNaN(x) || Number.isNaN(y))) + || (Array.isArray(value) && value.length !== 2) + ) { + return 'This field requires two numbers'; + } + + if (!options.required && typeof value[0] !== typeof value[1]) { + return 'This field requires two numbers or both can be empty'; + } + + return true; +}; + export default { number, text, @@ -216,4 +234,5 @@ export default { select, radio, blocks, + point, }; diff --git a/src/graphql/schema/buildMutationInputType.ts b/src/graphql/schema/buildMutationInputType.ts index dcac4e658e..32d9933309 100644 --- a/src/graphql/schema/buildMutationInputType.ts +++ b/src/graphql/schema/buildMutationInputType.ts @@ -30,6 +30,7 @@ function buildMutationInputType(name: string, fields: Field[], parentName: strin 'rich-text': (field: Field) => ({ type: withNullableType(field, GraphQLString, forceNullable) }), html: (field: Field) => ({ type: withNullableType(field, GraphQLString, forceNullable) }), radio: (field: Field) => ({ type: withNullableType(field, GraphQLString, forceNullable) }), + point: (field: Field) => ({ type: withNullableType(field, GraphQLList(GraphQLFloat), forceNullable) }), checkbox: () => ({ type: GraphQLBoolean }), select: (field: SelectField) => { const formattedName = `${combineParentName(parentName, field.name)}_MutationInput`; diff --git a/src/graphql/schema/buildObjectType.ts b/src/graphql/schema/buildObjectType.ts index 2f6b10ccdf..43720c4586 100644 --- a/src/graphql/schema/buildObjectType.ts +++ b/src/graphql/schema/buildObjectType.ts @@ -43,6 +43,7 @@ function buildObjectType(name: string, fields: Field[], parentName: string, base textarea: (field: Field) => ({ type: withNullableType(field, GraphQLString) }), code: (field: Field) => ({ type: withNullableType(field, GraphQLString) }), date: (field: Field) => ({ type: withNullableType(field, DateTimeResolver) }), + point: (field: Field) => ({ type: withNullableType(field, new GraphQLList(GraphQLFloat)) }), richText: (field: RichTextField) => ({ type: withNullableType(field, GraphQLJSON), async resolve(parent, args, context) { diff --git a/src/graphql/schema/buildWhereInputType.ts b/src/graphql/schema/buildWhereInputType.ts index 8b88b01a60..7863bedcaf 100644 --- a/src/graphql/schema/buildWhereInputType.ts +++ b/src/graphql/schema/buildWhereInputType.ts @@ -31,6 +31,7 @@ import { TextareaField, TextField, UploadField, + PointField, } from '../../fields/config/types'; import formatName from '../utilities/formatName'; import combineParentName from '../utilities/combineParentName'; @@ -83,6 +84,7 @@ const buildWhereInputType = (name: string, fields: Field[], parentName: string): equality: ['equals', 'not_equals'], contains: ['in', 'not_in', 'all'], comparison: ['greater_than_equal', 'greater_than', 'less_than_equal', 'less_than'], + geo: ['near'], }; const fieldToSchemaMap = { @@ -190,6 +192,17 @@ const buildWhereInputType = (name: string, fields: Field[], parentName: string): ), }; }, + point: (field: PointField) => { + const type = GraphQLList(GraphQLFloat); + return { + type: withOperators( + field, + type, + parentName, + [...operators.equality, ...operators.comparison, ...operators.geo], + ), + }; + }, relationship: (field: RelationshipField) => { let type = withOperators( field, diff --git a/src/mongoose/buildQuery.ts b/src/mongoose/buildQuery.ts index 1effc6fe58..10835adbc5 100644 --- a/src/mongoose/buildQuery.ts +++ b/src/mongoose/buildQuery.ts @@ -2,7 +2,7 @@ /* eslint-disable no-restricted-syntax */ import mongoose, { FilterQuery } from 'mongoose'; -const validOperators = ['like', 'in', 'all', 'not_in', 'greater_than_equal', 'greater_than', 'less_than_equal', 'less_than', 'not_equals', 'equals', 'exists']; +const validOperators = ['like', 'in', 'all', 'not_in', 'greater_than_equal', 'greater_than', 'less_than_equal', 'less_than', 'not_equals', 'equals', 'exists', 'near']; function addSearchParam(key, value, searchParams) { return { ...searchParams, @@ -216,6 +216,21 @@ class ParamParser { case 'exists': formattedValue = { $exists: (formattedValue === 'true' || formattedValue === true) }; break; + case 'near': + // eslint-disable-next-line no-case-declarations + const [x, y, maxDistance, minDistance] = convertArrayFromCommaDelineated(formattedValue); + if (!x || !y || (!maxDistance && !minDistance)) { + formattedValue = undefined; + break; + } + formattedValue = { + $near: { + $geometry: { type: 'Point', coordinates: [parseFloat(x), parseFloat(y)] }, + }, + }; + if (maxDistance) formattedValue.$near.$maxDistance = parseFloat(maxDistance); + if (minDistance) formattedValue.$near.$minDistance = parseFloat(minDistance); + break; default: break; } diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index 3c1df5dba2..ddc61531cf 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -49,6 +49,7 @@ const formatBaseSchema = (field: Field) => ({ const buildSchema = (config: SanitizedConfig, configFields: Field[], options = {}): Schema => { let fields = {}; + const indexFields = []; configFields.forEach((field) => { const fieldSchema: FieldSchemaGenerator = fieldToSchemaMap[field.type]; @@ -56,15 +57,31 @@ const buildSchema = (config: SanitizedConfig, configFields: Field[], options = { if (fieldSchema) { fields = fieldSchema(field, fields, config); } + // geospatial field index must be created after the schema is created + if (fieldIndexMap[field.type]) { + indexFields.push(...fieldIndexMap[field.type](field, config)); + } }); const schema = new Schema(fields, options); + indexFields.forEach((index) => { + schema.index(index); + }); setBlockDiscriminators(configFields, schema, config); return schema; }; +const fieldIndexMap = { + point: (field: Field, config: SanitizedConfig) => { + if (field.localized) { + return config.localization.locales.map((locale) => ({ [`${field.name}.${locale}`]: field.index === false ? undefined : field.index || '2dsphere' })); + } + return [{ [field.name]: field.index === false ? undefined : field.index || '2dsphere' }]; + }, +}; + const fieldToSchemaMap = { number: (field: Field, fields: SchemaDefinition, config: SanitizedConfig): SchemaDefinition => { const baseSchema = { ...formatBaseSchema(field), type: Number }; @@ -192,6 +209,39 @@ const fieldToSchemaMap = { [field.name]: schemaToReturn, }; }, + point: (field: Field, fields: SchemaDefinition, config: SanitizedConfig): SchemaDefinition => { + const baseSchema = { + type: { + type: String, + enum: ['Point'], + }, + coordinates: { + type: [Number], + sparse: field.unique && field.localized, + unique: field.unique || false, + required: (field.required && !field.localized && !field?.admin?.condition && !field?.access?.create) || false, + default: field.defaultValue || undefined, + }, + }; + let schemaToReturn; + + if (field.localized) { + schemaToReturn = { + type: config.localization.locales.reduce((localeSchema, locale) => ({ + ...localeSchema, + [locale]: baseSchema, + }), {}), + localized: true, + }; + } else { + schemaToReturn = baseSchema; + } + + return { + ...fields, + [field.name]: schemaToReturn, + }; + }, radio: (field: RadioField, fields: SchemaDefinition, config: SanitizedConfig): SchemaDefinition => { const baseSchema = { ...formatBaseSchema(field), diff --git a/src/types/index.ts b/src/types/index.ts index 2b037d196e..ee0db05368 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -11,6 +11,7 @@ export type Operator = 'equals' | 'less_than' | 'less_than_equals' | 'like' + | 'near' export type WhereField = { [key in Operator]?: unknown diff --git a/tests/api/globalSetup.js b/tests/api/globalSetup.js index 4184fd16c4..0ef93dcbfa 100644 --- a/tests/api/globalSetup.js +++ b/tests/api/globalSetup.js @@ -1,12 +1,21 @@ +const path = require('path'); +const fs = require('fs'); require('isomorphic-fetch'); require('../../demo/server'); const loadConfig = require('../../src/config/load').default; const { email, password } = require('../../src/mongoose/testCredentials'); +const fileExists = require('./utils/fileExists'); const { serverURL } = loadConfig(); +const mediaDir = path.join(__dirname, '../../demo', 'media'); + const globalSetup = async () => { + const mediaDirExists = await fileExists(mediaDir); + if (mediaDirExists) { + fs.rmdirSync(mediaDir, { recursive: true }); + } const response = await fetch(`${serverURL}/api/admins/first-register`, { body: JSON.stringify({ email,