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,