From 7504155e17a2881b7a60f49e610c062665b46d21 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Mon, 23 Aug 2021 23:12:50 -0400 Subject: [PATCH 1/8] feat: add point field type --- demo/collections/Geolocation.ts | 59 +++++++++ demo/payload.config.ts | 2 + .../forms/field-types/Point/index.scss | 16 +++ .../forms/field-types/Point/index.tsx | 112 ++++++++++++++++++ .../forms/field-types/Point/types.ts | 5 + .../components/forms/field-types/index.tsx | 3 + src/collections/buildSchema.ts | 6 +- src/collections/tests/pointField.spec.js | 71 +++++++++++ src/fields/config/schema.ts | 6 + src/fields/config/types.ts | 10 ++ src/fields/performFieldOperations.ts | 10 ++ src/fields/traverseFields.ts | 22 ++++ src/fields/validations.ts | 19 +++ src/graphql/schema/buildMutationInputType.ts | 1 + src/graphql/schema/buildObjectType.ts | 1 + src/graphql/schema/buildWhereInputType.ts | 12 ++ src/mongoose/buildSchema.ts | 47 ++++++++ 17 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 demo/collections/Geolocation.ts create mode 100644 src/admin/components/forms/field-types/Point/index.scss create mode 100644 src/admin/components/forms/field-types/Point/index.tsx create mode 100644 src/admin/components/forms/field-types/Point/types.ts create mode 100644 src/collections/tests/pointField.spec.js diff --git a/demo/collections/Geolocation.ts b/demo/collections/Geolocation.ts new file mode 100644 index 0000000000..7773e4c542 --- /dev/null +++ b/demo/collections/Geolocation.ts @@ -0,0 +1,59 @@ +/* eslint-disable no-param-reassign */ +import { CollectionConfig } from '../../src/collections/config/types'; + +const Geolocation: CollectionConfig = { + slug: 'geolocation', + labels: { + singular: 'Geolocation', + plural: 'Geolocations', + }, + 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; + // console.log(doc); + doc.afterReadHook = !doc.location?.coordinates; + return doc; + }, + ], + afterChange: [ + (operation) => { + const { doc } = operation; + operation.doc.afterChangeHook = !doc.location?.coordinates; + return operation.doc; + }, + ], + afterDelete: [ + (operation) => { + const { doc } = operation; + operation.doc.afterDeleteHook = !doc.location?.coordinates; + return operation.doc; + }, + ], + }, + fields: [ + { + name: 'location', + type: 'point', + label: 'Location', + }, + { + name: 'localizedPoint', + type: 'point', + label: 'Localized Point', + localized: true, + }, + ], +}; + +export default Geolocation; diff --git a/demo/payload.config.ts b/demo/payload.config.ts index b621c7ed21..49530cee28 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, 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..59644ecd09 --- /dev/null +++ b/src/admin/components/forms/field-types/Point/index.scss @@ -0,0 +1,16 @@ +@import '../../../../scss/styles.scss'; + +.field-type.number { + position: relative; + margin-bottom: $baseline; + + 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..f14e1a7ffd --- /dev/null +++ b/src/admin/components/forms/field-types/Point/index.tsx @@ -0,0 +1,112 @@ +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 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', + 'number', + 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/buildSchema.ts b/src/collections/buildSchema.ts index 6a3be5edee..0453693782 100644 --- a/src/collections/buildSchema.ts +++ b/src/collections/buildSchema.ts @@ -3,7 +3,11 @@ import buildQueryPlugin from '../mongoose/buildQuery'; import buildSchema from '../mongoose/buildSchema'; const buildCollectionSchema = (collection, config, schemaOptions = {}) => { - const schema = buildSchema(config, collection.fields, { timestamps: collection.timestamps !== false, ...schemaOptions }); + const schema = buildSchema( + config, + collection.fields, + { timestamps: collection.timestamps !== false, ...schemaOptions }, + ); schema.plugin(paginate) .plugin(buildQueryPlugin); diff --git a/src/collections/tests/pointField.spec.js b/src/collections/tests/pointField.spec.js new file mode 100644 index 0000000000..981b552e03 --- /dev/null +++ b/src/collections/tests/pointField.spec.js @@ -0,0 +1,71 @@ +import getConfig from '../../config/load'; +import { email, password } from '../../mongoose/testCredentials'; + +require('isomorphic-fetch'); + +const { serverURL: url } = getConfig(); + +let token = null; +let headers = null; + +describe('GeoJSON', () => { + beforeAll(async (done) => { + const response = await fetch(`${url}/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', () => { + const location = { + coordinates: [100, 200], + }; + let doc; + + beforeAll(async (done) => { + // create document a + const create = await fetch(`${url}/api/geolocation`, { + body: JSON.stringify({ location }), + headers, + method: 'post', + }); + ({ doc } = await create.json()); + + done(); + }); + + it('should create and read collections with points', async () => { + expect(doc).not.toBeNull(); + console.log(doc); + }); + + it('should query for nearest point', async () => { + const q = `${url}/api/geolocation?location[near]=${location.coordinates}`; + console.log(q); + const response = await fetch(`${url}/api/geolocation?where[location][near]=${location.coordinates}`, { + headers, + method: 'get', + }); + const data = await response.json(); + [doc] = data.docs; + + expect(data.docs).toHaveLength(1); + }); + }); +}); diff --git a/src/fields/config/schema.ts b/src/fields/config/schema.ts index 49eb9d38d3..bd3808da2f 100644 --- a/src/fields/config/schema.ts +++ b/src/fields/config/schema.ts @@ -185,6 +185,11 @@ export const checkbox = baseField.keys({ defaultValue: joi.boolean(), }); +export const point = baseField.keys({ + type: joi.string().valid('point').required(), + name: joi.string().required(), +}); + export const relationship = baseField.keys({ type: joi.string().valid('relationship').required(), hasMany: joi.boolean().default(false), @@ -290,6 +295,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..81b347b559 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 & { @@ -278,6 +283,11 @@ export type FieldWithMaxDepth = UploadField | RelationshipField +export type PointValue = { + type: 'Point'; + coordinates: [number, number]; +} + export function fieldHasSubFields(field: Field): field is FieldWithSubFields { return (field.type === 'group' || field.type === 'array' || field.type === 'row'); } diff --git a/src/fields/performFieldOperations.ts b/src/fields/performFieldOperations.ts index 2775f8963c..944fc63fad 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,6 +99,7 @@ export default async function performFieldOperations(this: Payload, entityConfig showHiddenFields, unflattenLocales, unflattenLocaleActions, + transformActions, docWithLocales, }); @@ -111,8 +113,16 @@ export default async function performFieldOperations(this: Payload, entityConfig throw new ValidationError(errors); } + if (hook === 'beforeChange') { + transformActions.forEach((action) => action()); + } + unflattenLocaleActions.forEach((action) => action()); + if (hook === 'afterRead') { + transformActions.forEach((action) => action()); + } + await Promise.all(accessPromises); const relationshipPopulationPromises = relationshipPopulations.map((population) => population()); diff --git a/src/fields/traverseFields.ts b/src/fields/traverseFields.ts index 680afab4c9..b2a74ef153 100644 --- a/src/fields/traverseFields.ts +++ b/src/fields/traverseFields.ts @@ -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; @@ -257,6 +267,18 @@ const traverseFields = (args: Arguments): void => { updatedData[field.name] = field.defaultValue; } + if (field.type === 'point' && data[field.name]) { + transformActions.push(() => { + 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..e84a155fd7 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'; @@ -190,6 +191,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, 'near'], + ), + }; + }, relationship: (field: RelationshipField) => { let type = withOperators( field, diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index 3c1df5dba2..20e12969f5 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -49,22 +49,34 @@ 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]; if (fieldSchema) { fields = fieldSchema(field, fields, config); + // geospatial field index must be created after the schema is created + if (fieldIndexMap[field.type]) { + indexFields.push(field); + } } }); const schema = new Schema(fields, options); + indexFields.forEach((index) => { + schema.index(index); + }); setBlockDiscriminators(configFields, schema, config); return schema; }; +const fieldIndexMap = { + point: (field: Field) => ({ [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 +204,41 @@ const fieldToSchemaMap = { [field.name]: schemaToReturn, }; }, + point: (field: Field, fields: SchemaDefinition, config: SanitizedConfig): SchemaDefinition => { + const baseSchema = { + type: { + type: String, + default: 'Point', + enum: ['Point'], + required: true, + }, + 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), From 30f17509ea9927d923ffd42c703adefc902b66ea Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Tue, 24 Aug 2021 17:28:08 -0400 Subject: [PATCH 2/8] feat: point field localization and graphql --- demo/collections/Geolocation.ts | 3 + .../elements/WhereBuilder/field-types.tsx | 13 ++ src/collections/bindCollection.ts | 11 +- src/collections/buildSchema.ts | 7 +- src/collections/init.ts | 3 +- src/collections/operations/find.ts | 1 + src/collections/tests/pointField.spec.js | 135 +++++++++++++++--- src/express/types.ts | 2 +- src/graphql/schema/buildWhereInputType.ts | 3 +- src/mongoose/buildQuery.ts | 13 +- src/mongoose/buildSchema.ts | 15 +- src/types/index.ts | 1 + 12 files changed, 174 insertions(+), 33 deletions(-) diff --git a/demo/collections/Geolocation.ts b/demo/collections/Geolocation.ts index 7773e4c542..1a4205ab5a 100644 --- a/demo/collections/Geolocation.ts +++ b/demo/collections/Geolocation.ts @@ -7,6 +7,9 @@ const Geolocation: CollectionConfig = { singular: 'Geolocation', plural: 'Geolocations', }, + access: { + read: () => true, + }, hooks: { beforeRead: [ (operation) => operation.doc, diff --git a/src/admin/components/elements/WhereBuilder/field-types.tsx b/src/admin/components/elements/WhereBuilder/field-types.tsx index 1cdf649d8e..870f981ce1 100644 --- a/src/admin/components/elements/WhereBuilder/field-types.tsx +++ b/src/admin/components/elements/WhereBuilder/field-types.tsx @@ -45,6 +45,15 @@ const numeric = [ }, ]; +const geo = [ + ...base, + ...boolean, + { + label: 'near', + value: 'near', + }, +]; + const like = { label: 'is like', value: 'like', @@ -79,6 +88,10 @@ const fieldTypeConditions = { component: 'Date', operators: [...base, ...numeric], }, + point: { + component: 'Point', + operators: [...geo], + }, upload: { component: 'Text', operators: [...base], diff --git a/src/collections/bindCollection.ts b/src/collections/bindCollection.ts index 5499a95c72..6a50e04982 100644 --- a/src/collections/bindCollection.ts +++ b/src/collections/bindCollection.ts @@ -1,8 +1,9 @@ -const bindCollectionMiddleware = (collection) => { - return (req, res, next) => { - req.collection = collection; - next(); - }; +import { NextFunction, Response } from 'express'; +import { PayloadRequest } from '../express/types'; + +const bindCollectionMiddleware = (collection: string) => (req: PayloadRequest, res: Response, next: NextFunction) => { + req.collection = collection; + next(); }; export default bindCollectionMiddleware; diff --git a/src/collections/buildSchema.ts b/src/collections/buildSchema.ts index 0453693782..1cd583a285 100644 --- a/src/collections/buildSchema.ts +++ b/src/collections/buildSchema.ts @@ -1,15 +1,18 @@ import paginate from 'mongoose-paginate-v2'; +import { Schema } from 'mongoose'; +import { SanitizedConfig } from '../../config'; import buildQueryPlugin from '../mongoose/buildQuery'; import buildSchema from '../mongoose/buildSchema'; +import { SanitizedCollectionConfig } from './config/types'; -const buildCollectionSchema = (collection, config, 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..1159c8da27 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'; 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/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/pointField.spec.js b/src/collections/tests/pointField.spec.js index 981b552e03..057c591ad1 100644 --- a/src/collections/tests/pointField.spec.js +++ b/src/collections/tests/pointField.spec.js @@ -1,16 +1,17 @@ +import { GraphQLClient } from 'graphql-request'; import getConfig from '../../config/load'; import { email, password } from '../../mongoose/testCredentials'; require('isomorphic-fetch'); -const { serverURL: url } = getConfig(); +const { serverURL, routes } = getConfig(); let token = null; let headers = null; describe('GeoJSON', () => { beforeAll(async (done) => { - const response = await fetch(`${url}/api/admins/login`, { + const response = await fetch(`${serverURL}/api/admins/login`, { body: JSON.stringify({ email, password, @@ -32,16 +33,15 @@ describe('GeoJSON', () => { done(); }); - describe('Point Field', () => { - const location = { - coordinates: [100, 200], - }; + describe('Point Field - REST', () => { + const location = [10, 20]; + const localizedPoint = [30, 40]; let doc; beforeAll(async (done) => { // create document a - const create = await fetch(`${url}/api/geolocation`, { - body: JSON.stringify({ location }), + const create = await fetch(`${serverURL}/api/geolocation`, { + body: JSON.stringify({ location, localizedPoint }), headers, method: 'post', }); @@ -51,21 +51,122 @@ describe('GeoJSON', () => { }); it('should create and read collections with points', async () => { - expect(doc).not.toBeNull(); - console.log(doc); + expect(doc.id).not.toBeNull(); + expect(doc.location).toStrictEqual(location); + expect(doc.localizedPoint).toStrictEqual(localizedPoint); }); - it('should query for nearest point', async () => { - const q = `${url}/api/geolocation?location[near]=${location.coordinates}`; - console.log(q); - const response = await fetch(`${url}/api/geolocation?where[location][near]=${location.coordinates}`, { + 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 data = await response.json(); - [doc] = data.docs; + const hitData = await hitResponse.json(); + const hitDocs = hitData.docs; - expect(data.docs).toHaveLength(1); + 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/express/types.ts b/src/express/types.ts index e8b6644519..24d7763d8d 100644 --- a/src/express/types.ts +++ b/src/express/types.ts @@ -9,7 +9,7 @@ export type PayloadRequest = Request & { payload: Payload; locale?: string; fallbackLocale?: string; - collection?: Collection; + collection?: Collection | string; payloadAPI: 'REST' | 'local' | 'graphQL' file?: UploadedFile user: User | null diff --git a/src/graphql/schema/buildWhereInputType.ts b/src/graphql/schema/buildWhereInputType.ts index e84a155fd7..7863bedcaf 100644 --- a/src/graphql/schema/buildWhereInputType.ts +++ b/src/graphql/schema/buildWhereInputType.ts @@ -84,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 = { @@ -198,7 +199,7 @@ const buildWhereInputType = (name: string, fields: Field[], parentName: string): field, type, parentName, - [...operators.equality, ...operators.comparison, 'near'], + [...operators.equality, ...operators.comparison, ...operators.geo], ), }; }, diff --git a/src/mongoose/buildQuery.ts b/src/mongoose/buildQuery.ts index 1effc6fe58..e485f45a12 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,17 @@ class ParamParser { case 'exists': formattedValue = { $exists: (formattedValue === 'true' || formattedValue === true) }; break; + case 'near': + // eslint-disable-next-line no-case-declarations + const [longitude, latitude, maxDistance, minDistance] = convertArrayFromCommaDelineated(formattedValue); + formattedValue = { + $near: { + $geometry: { type: 'Point', coordinates: [parseFloat(longitude), parseFloat(latitude)] }, + }, + }; + if (maxDistance) formattedValue.$near.$maxDistance = parseFloat(maxDistance); + if (minDistance) formattedValue.$near.$maxDistance = parseFloat(maxDistance); + break; default: break; } diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index 20e12969f5..9de2cde969 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -56,10 +56,10 @@ 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(field); - } + } + // geospatial field index must be created after the schema is created + if (fieldIndexMap[field.type]) { + indexFields.push(...fieldIndexMap[field.type](field, config)); } }); @@ -74,7 +74,12 @@ const buildSchema = (config: SanitizedConfig, configFields: Field[], options = { }; const fieldIndexMap = { - point: (field: Field) => ({ [field.name]: field.index === false ? undefined : field.index || '2dsphere' }), + 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 = { 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 From f80646c5987db4c228b00beda9549259021c2a40 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Wed, 25 Aug 2021 13:28:42 -0400 Subject: [PATCH 3/8] fix: allow save of collection with an undefined point --- demo/collections/Geolocation.ts | 21 +++++++++++++++++---- demo/collections/Localized.ts | 14 ++++++++++++++ src/collections/bindCollection.ts | 5 ++--- src/collections/buildSchema.ts | 2 +- src/collections/init.ts | 2 +- src/collections/tests/pointField.spec.js | 1 - src/express/types.ts | 2 +- src/fields/hookPromise.ts | 4 ++-- src/fields/performFieldOperations.ts | 23 +++++++++++++---------- src/fields/traverseFields.ts | 18 ++++++++++-------- src/mongoose/buildSchema.ts | 2 -- 11 files changed, 61 insertions(+), 33 deletions(-) diff --git a/demo/collections/Geolocation.ts b/demo/collections/Geolocation.ts index 1a4205ab5a..b0e92e0535 100644 --- a/demo/collections/Geolocation.ts +++ b/demo/collections/Geolocation.ts @@ -1,6 +1,14 @@ /* eslint-disable no-param-reassign */ import { CollectionConfig } from '../../src/collections/config/types'; +const validateFieldTransformAction = (hook: string, value = null) => { + if (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: { @@ -24,7 +32,6 @@ const Geolocation: CollectionConfig = { afterRead: [ (operation) => { const { doc } = operation; - // console.log(doc); doc.afterReadHook = !doc.location?.coordinates; return doc; }, @@ -32,15 +39,15 @@ const Geolocation: CollectionConfig = { afterChange: [ (operation) => { const { doc } = operation; - operation.doc.afterChangeHook = !doc.location?.coordinates; - return operation.doc; + doc.afterChangeHook = !doc.location?.coordinates; + return doc; }, ], afterDelete: [ (operation) => { const { doc } = operation; operation.doc.afterDeleteHook = !doc.location?.coordinates; - return operation.doc; + return doc; }, ], }, @@ -49,6 +56,12 @@ const Geolocation: CollectionConfig = { 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', diff --git a/demo/collections/Localized.ts b/demo/collections/Localized.ts index f222bb2a30..87970f3d68 100644 --- a/demo/collections/Localized.ts +++ b/demo/collections/Localized.ts @@ -1,6 +1,14 @@ import { CollectionConfig } from '../../src/collections/config/types'; import { Block } from '../../src/fields/config/types'; +const validateLocalizationTransform = (hook: string, value = null) => { + if (value !== null && typeof value !== 'string') { + console.error(hook, value); + throw new Error('Field text transformation in hook is wonky'); + } + return value; +}; + const RichTextBlock: Block = { slug: 'richTextBlock', labels: { @@ -46,6 +54,12 @@ const LocalizedPosts: CollectionConfig = { required: true, unique: true, localized: true, + hooks: { + beforeValidate: [({ value }) => validateLocalizationTransform('beforeValidate', value)], + beforeChange: [({ value }) => validateLocalizationTransform('beforeChange', value)], + afterChange: [({ value }) => validateLocalizationTransform('afterChange', value)], + afterRead: [({ value }) => validateLocalizationTransform('afterRead', value)], + }, }, { name: 'summary', diff --git a/src/collections/bindCollection.ts b/src/collections/bindCollection.ts index 6a50e04982..2242b5fa4c 100644 --- a/src/collections/bindCollection.ts +++ b/src/collections/bindCollection.ts @@ -1,7 +1,6 @@ -import { NextFunction, Response } from 'express'; -import { PayloadRequest } from '../express/types'; +import { NextFunction, Request, Response } from 'express'; -const bindCollectionMiddleware = (collection: string) => (req: PayloadRequest, res: Response, next: NextFunction) => { +const bindCollectionMiddleware = (collection: string) => (req: Request & { collection: string }, res: Response, next: NextFunction) => { req.collection = collection; next(); }; diff --git a/src/collections/buildSchema.ts b/src/collections/buildSchema.ts index 1cd583a285..df5d3c0541 100644 --- a/src/collections/buildSchema.ts +++ b/src/collections/buildSchema.ts @@ -1,6 +1,6 @@ import paginate from 'mongoose-paginate-v2'; import { Schema } from 'mongoose'; -import { SanitizedConfig } from '../../config'; +import { SanitizedConfig } from '../config/types'; import buildQueryPlugin from '../mongoose/buildQuery'; import buildSchema from '../mongoose/buildSchema'; import { SanitizedCollectionConfig } from './config/types'; diff --git a/src/collections/init.ts b/src/collections/init.ts index 1159c8da27..b792fb75fe 100644 --- a/src/collections/init.ts +++ b/src/collections/init.ts @@ -8,7 +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'; +import { SanitizedConfig } from '../config/types'; import { Payload } from '../index'; const LocalStrategy = Passport.Strategy; diff --git a/src/collections/tests/pointField.spec.js b/src/collections/tests/pointField.spec.js index 057c591ad1..a0d412baf7 100644 --- a/src/collections/tests/pointField.spec.js +++ b/src/collections/tests/pointField.spec.js @@ -39,7 +39,6 @@ describe('GeoJSON', () => { let doc; beforeAll(async (done) => { - // create document a const create = await fetch(`${serverURL}/api/geolocation`, { body: JSON.stringify({ location, localizedPoint }), headers, diff --git a/src/express/types.ts b/src/express/types.ts index 24d7763d8d..e8b6644519 100644 --- a/src/express/types.ts +++ b/src/express/types.ts @@ -9,7 +9,7 @@ export type PayloadRequest = Request & { payload: Payload; locale?: string; fallbackLocale?: string; - collection?: Collection | string; + collection?: Collection; payloadAPI: 'REST' | 'local' | 'graphQL' file?: UploadedFile user: User | null diff --git a/src/fields/hookPromise.ts b/src/fields/hookPromise.ts index e336398715..c03a9be78a 100644 --- a/src/fields/hookPromise.ts +++ b/src/fields/hookPromise.ts @@ -12,7 +12,7 @@ type Arguments = { fullData: Record } -const hookPromise = async ({ +const hookPromise = ({ data, field, hook, @@ -20,7 +20,7 @@ const hookPromise = async ({ operation, fullOriginalDoc, fullData, -}: Arguments): Promise => { +}: Arguments) => async (): Promise => { const resultingData = data; if (field.hooks && field.hooks[hook]) { diff --git a/src/fields/performFieldOperations.ts b/src/fields/performFieldOperations.ts index 944fc63fad..1875d0f699 100644 --- a/src/fields/performFieldOperations.ts +++ b/src/fields/performFieldOperations.ts @@ -103,16 +103,6 @@ export default async function performFieldOperations(this: Payload, entityConfig docWithLocales, }); - await Promise.all(hookPromises); - - validationPromises.forEach((promise) => promise()); - - await Promise.all(validationPromises); - - if (errors.length > 0) { - throw new ValidationError(errors); - } - if (hook === 'beforeChange') { transformActions.forEach((action) => action()); } @@ -123,6 +113,19 @@ export default async function performFieldOperations(this: Payload, entityConfig transformActions.forEach((action) => action()); } + hookPromises.forEach((promise) => promise()); + + await Promise.all(hookPromises); + + validationPromises.forEach((promise) => promise()); + + await Promise.all(validationPromises); + + if (errors.length > 0) { + throw new ValidationError(errors); + } + + await Promise.all(accessPromises); const relationshipPopulationPromises = relationshipPopulations.map((population) => population()); diff --git a/src/fields/traverseFields.ts b/src/fields/traverseFields.ts index b2a74ef153..744b018e3f 100644 --- a/src/fields/traverseFields.ts +++ b/src/fields/traverseFields.ts @@ -24,7 +24,7 @@ type Arguments = { depth: number currentDepth: number hook: HookName - hookPromises: Promise[] + hookPromises: (() => Promise)[] fullOriginalDoc: Record fullData: Record validationPromises: (() => Promise)[] @@ -269,13 +269,15 @@ const traverseFields = (args: Arguments): void => { if (field.type === 'point' && data[field.name]) { transformActions.push(() => { - data[field.name] = { - type: 'Point', - coordinates: [ - parseFloat(data[field.name][0]), - parseFloat(data[field.name][1]), - ], - }; + 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]), + ], + }; + } }); } diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index 9de2cde969..ddc61531cf 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -213,9 +213,7 @@ const fieldToSchemaMap = { const baseSchema = { type: { type: String, - default: 'Point', enum: ['Point'], - required: true, }, coordinates: { type: [Number], From c187da00b1f18c66d9252a5a3e2029455d75b371 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 25 Aug 2021 15:38:46 -0400 Subject: [PATCH 4/8] fix: ensures proper order while transforming incoming and outgoing data --- demo/collections/Localized.ts | 13 +++--- demo/payload.config.ts | 2 +- src/collections/operations/create.ts | 56 +++++++++++------------ src/collections/tests/collections.spec.js | 1 - src/fields/performFieldOperations.ts | 11 ++--- 5 files changed, 41 insertions(+), 42 deletions(-) diff --git a/demo/collections/Localized.ts b/demo/collections/Localized.ts index 87970f3d68..05ae47d0a7 100644 --- a/demo/collections/Localized.ts +++ b/demo/collections/Localized.ts @@ -1,8 +1,9 @@ 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 = null) => { - if (value !== null && typeof value !== 'string') { +const validateLocalizationTransform = (hook: string, value, req: PayloadRequest) => { + if (req.locale !== 'all' && value !== undefined && typeof value !== 'string') { console.error(hook, value); throw new Error('Field text transformation in hook is wonky'); } @@ -55,10 +56,10 @@ const LocalizedPosts: CollectionConfig = { unique: true, localized: true, hooks: { - beforeValidate: [({ value }) => validateLocalizationTransform('beforeValidate', value)], - beforeChange: [({ value }) => validateLocalizationTransform('beforeChange', value)], - afterChange: [({ value }) => validateLocalizationTransform('afterChange', value)], - afterRead: [({ value }) => validateLocalizationTransform('afterRead', value)], + 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)], }, }, { diff --git a/demo/payload.config.ts b/demo/payload.config.ts index 49530cee28..237c102c9e 100644 --- a/demo/payload.config.ts +++ b/demo/payload.config.ts @@ -110,7 +110,7 @@ export default buildConfig({ defaultDepth: 2, graphQL: { maxComplexity: 1000, - disablePlaygroundInProduction: true, + disablePlaygroundInProduction: false, disable: false, }, // rateLimit: { 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/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/fields/performFieldOperations.ts b/src/fields/performFieldOperations.ts index 1875d0f699..e840045bbb 100644 --- a/src/fields/performFieldOperations.ts +++ b/src/fields/performFieldOperations.ts @@ -103,12 +103,6 @@ export default async function performFieldOperations(this: Payload, entityConfig docWithLocales, }); - if (hook === 'beforeChange') { - transformActions.forEach((action) => action()); - } - - unflattenLocaleActions.forEach((action) => action()); - if (hook === 'afterRead') { transformActions.forEach((action) => action()); } @@ -125,6 +119,11 @@ export default async function performFieldOperations(this: Payload, entityConfig throw new ValidationError(errors); } + if (hook === 'beforeChange') { + transformActions.forEach((action) => action()); + } + + unflattenLocaleActions.forEach((action) => action()); await Promise.all(accessPromises); From 34630757b921aa0ead076f22e99e734c2129ff4b Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Wed, 25 Aug 2021 16:55:47 -0400 Subject: [PATCH 5/8] docs: point field and near query --- demo/collections/Geolocation.ts | 10 +++- demo/collections/Localized.ts | 2 +- docs/fields/overview.mdx | 1 + docs/fields/point.mdx | 54 +++++++++++++++++++ docs/queries/overview.mdx | 1 + .../elements/WhereBuilder/field-types.tsx | 5 +- src/fields/config/schema.ts | 1 + src/mongoose/buildQuery.ts | 10 ++-- tests/api/globalSetup.js | 5 ++ 9 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 docs/fields/point.mdx diff --git a/demo/collections/Geolocation.ts b/demo/collections/Geolocation.ts index b0e92e0535..e9adffa5f1 100644 --- a/demo/collections/Geolocation.ts +++ b/demo/collections/Geolocation.ts @@ -1,8 +1,8 @@ /* eslint-disable no-param-reassign */ import { CollectionConfig } from '../../src/collections/config/types'; -const validateFieldTransformAction = (hook: string, value = null) => { - if (value !== null && !Array.isArray(value)) { +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] }'); } @@ -68,6 +68,12 @@ const Geolocation: CollectionConfig = { 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)], + }, }, ], }; diff --git a/demo/collections/Localized.ts b/demo/collections/Localized.ts index 05ae47d0a7..2b0f173d94 100644 --- a/demo/collections/Localized.ts +++ b/demo/collections/Localized.ts @@ -5,7 +5,7 @@ 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('Field text transformation in hook is wonky'); + throw new Error('Locale transformation should happen before hook is called'); } return value; }; 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 870f981ce1..6839f7829e 100644 --- a/src/admin/components/elements/WhereBuilder/field-types.tsx +++ b/src/admin/components/elements/WhereBuilder/field-types.tsx @@ -46,8 +46,11 @@ const numeric = [ ]; const geo = [ - ...base, ...boolean, + { + label: 'exists', + value: 'exists', + }, { label: 'near', value: 'near', diff --git a/src/fields/config/schema.ts b/src/fields/config/schema.ts index bd3808da2f..39ea69b62a 100644 --- a/src/fields/config/schema.ts +++ b/src/fields/config/schema.ts @@ -188,6 +188,7 @@ export const checkbox = baseField.keys({ 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({ diff --git a/src/mongoose/buildQuery.ts b/src/mongoose/buildQuery.ts index e485f45a12..10835adbc5 100644 --- a/src/mongoose/buildQuery.ts +++ b/src/mongoose/buildQuery.ts @@ -218,14 +218,18 @@ class ParamParser { break; case 'near': // eslint-disable-next-line no-case-declarations - const [longitude, latitude, maxDistance, minDistance] = convertArrayFromCommaDelineated(formattedValue); + const [x, y, maxDistance, minDistance] = convertArrayFromCommaDelineated(formattedValue); + if (!x || !y || (!maxDistance && !minDistance)) { + formattedValue = undefined; + break; + } formattedValue = { $near: { - $geometry: { type: 'Point', coordinates: [parseFloat(longitude), parseFloat(latitude)] }, + $geometry: { type: 'Point', coordinates: [parseFloat(x), parseFloat(y)] }, }, }; if (maxDistance) formattedValue.$near.$maxDistance = parseFloat(maxDistance); - if (minDistance) formattedValue.$near.$maxDistance = parseFloat(maxDistance); + if (minDistance) formattedValue.$near.$minDistance = parseFloat(minDistance); break; default: break; diff --git a/tests/api/globalSetup.js b/tests/api/globalSetup.js index 4184fd16c4..01c2951537 100644 --- a/tests/api/globalSetup.js +++ b/tests/api/globalSetup.js @@ -1,3 +1,5 @@ +const path = require('path'); +const fs = require('fs'); require('isomorphic-fetch'); require('../../demo/server'); @@ -6,7 +8,10 @@ const { email, password } = require('../../src/mongoose/testCredentials'); const { serverURL } = loadConfig(); +const mediaDir = path.join(__dirname, '../../demo', 'media'); + const globalSetup = async () => { + fs.rmdirSync(mediaDir, { recursive: true }); const response = await fetch(`${serverURL}/api/admins/first-register`, { body: JSON.stringify({ email, From d42d8f76efcda7a24f2f50d60caf47b1027d81f6 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Wed, 25 Aug 2021 17:41:24 -0400 Subject: [PATCH 6/8] fix: remove media directory to improve test run consistency --- tests/api/globalSetup.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/api/globalSetup.js b/tests/api/globalSetup.js index 01c2951537..0ef93dcbfa 100644 --- a/tests/api/globalSetup.js +++ b/tests/api/globalSetup.js @@ -5,13 +5,17 @@ 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 () => { - fs.rmdirSync(mediaDir, { recursive: true }); + 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, From 96629f1f0100efdb9c5ad57c1a46add3c15ea65d Mon Sep 17 00:00:00 2001 From: James Date: Mon, 30 Aug 2021 17:10:06 -0400 Subject: [PATCH 7/8] fix: ensures hooks run before access --- src/fields/hookPromise.ts | 4 ++-- src/fields/performFieldOperations.ts | 14 ++++++-------- src/fields/traverseFields.ts | 6 +++--- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/fields/hookPromise.ts b/src/fields/hookPromise.ts index c03a9be78a..e336398715 100644 --- a/src/fields/hookPromise.ts +++ b/src/fields/hookPromise.ts @@ -12,7 +12,7 @@ type Arguments = { fullData: Record } -const hookPromise = ({ +const hookPromise = async ({ data, field, hook, @@ -20,7 +20,7 @@ const hookPromise = ({ operation, fullOriginalDoc, fullData, -}: Arguments) => async (): Promise => { +}: Arguments): Promise => { const resultingData = data; if (field.hooks && field.hooks[hook]) { diff --git a/src/fields/performFieldOperations.ts b/src/fields/performFieldOperations.ts index e840045bbb..02f4b25cf3 100644 --- a/src/fields/performFieldOperations.ts +++ b/src/fields/performFieldOperations.ts @@ -107,12 +107,10 @@ export default async function performFieldOperations(this: Payload, entityConfig transformActions.forEach((action) => action()); } - hookPromises.forEach((promise) => promise()); - - await Promise.all(hookPromises); + const hookResults = hookPromises.map((promise) => promise()); + await Promise.all(hookResults); validationPromises.forEach((promise) => promise()); - await Promise.all(validationPromises); if (errors.length > 0) { @@ -125,11 +123,11 @@ export default async function performFieldOperations(this: Payload, entityConfig 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 744b018e3f..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 @@ -173,7 +173,7 @@ const traverseFields = (args: Arguments): void => { }); } - accessPromises.push(accessPromise({ + accessPromises.push(() => accessPromise({ data, fullData, originalDoc, @@ -189,7 +189,7 @@ const traverseFields = (args: Arguments): void => { payload, })); - hookPromises.push(hookPromise({ + hookPromises.push(() => hookPromise({ data, field, hook, From 64ad6a30a56969127dfb592a7e0c8807e9f3d8f7 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 31 Aug 2021 13:29:17 -0400 Subject: [PATCH 8/8] feat: frontend polish to point field --- .../forms/field-types/Point/index.scss | 16 ++++- .../forms/field-types/Point/index.tsx | 66 +++++++++++-------- src/fields/config/types.ts | 5 -- 3 files changed, 54 insertions(+), 33 deletions(-) diff --git a/src/admin/components/forms/field-types/Point/index.scss b/src/admin/components/forms/field-types/Point/index.scss index 59644ecd09..aa6cd53533 100644 --- a/src/admin/components/forms/field-types/Point/index.scss +++ b/src/admin/components/forms/field-types/Point/index.scss @@ -1,9 +1,23 @@ @import '../../../../scss/styles.scss'; -.field-type.number { +.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; } diff --git a/src/admin/components/forms/field-types/Point/index.tsx b/src/admin/components/forms/field-types/Point/index.tsx index f14e1a7ffd..62f0cc1759 100644 --- a/src/admin/components/forms/field-types/Point/index.tsx +++ b/src/admin/components/forms/field-types/Point/index.tsx @@ -9,6 +9,8 @@ import { Props } from './types'; import './index.scss'; +const baseClass = 'point'; + const PointField: React.FC = (props) => { const { name, @@ -58,7 +60,7 @@ const PointField: React.FC = (props) => { const classes = [ 'field-type', - 'number', + baseClass, showError && 'error', readOnly && 'read-only', ].filter(Boolean).join(' '); @@ -75,32 +77,42 @@ const PointField: React.FC = (props) => { showError={showError} message={errorMessage} /> -