diff --git a/.changeset/friendly-crabs-guess/changes.json b/.changeset/friendly-crabs-guess/changes.json new file mode 100644 index 00000000000..4d9648e8136 --- /dev/null +++ b/.changeset/friendly-crabs-guess/changes.json @@ -0,0 +1 @@ +{ "releases": [{ "name": "@keystone-alpha/fields", "type": "minor" }], "dependents": [] } diff --git a/.changeset/friendly-crabs-guess/changes.md b/.changeset/friendly-crabs-guess/changes.md new file mode 100644 index 00000000000..0cd80379c2c --- /dev/null +++ b/.changeset/friendly-crabs-guess/changes.md @@ -0,0 +1 @@ +Add Location field \ No newline at end of file diff --git a/.changeset/sour-kiwis-try/changes.json b/.changeset/sour-kiwis-try/changes.json new file mode 100644 index 00000000000..bbd64cda5a0 --- /dev/null +++ b/.changeset/sour-kiwis-try/changes.json @@ -0,0 +1,18 @@ +{ + "releases": [{ "name": "@arch-ui/select", "type": "minor" }], + "dependents": [ + { + "name": "@keystone-alpha/app-admin-ui", + "type": "patch", + "dependencies": ["@keystone-alpha/fields", "@arch-ui/select"] + }, + { "name": "@arch-ui/docs", "type": "patch", "dependencies": ["@arch-ui/select"] }, + { "name": "@arch-ui/day-picker", "type": "patch", "dependencies": ["@arch-ui/select"] }, + { + "name": "@keystone-alpha/fields", + "type": "patch", + "dependencies": ["@arch-ui/day-picker", "@arch-ui/select"] + }, + { "name": "@keystone-alpha/website", "type": "patch", "dependencies": ["@arch-ui/select"] } + ] +} diff --git a/.changeset/sour-kiwis-try/changes.md b/.changeset/sour-kiwis-try/changes.md new file mode 100644 index 00000000000..eb1b99e549f --- /dev/null +++ b/.changeset/sour-kiwis-try/changes.md @@ -0,0 +1 @@ +Allow async and creatable react-selects \ No newline at end of file diff --git a/package.json b/package.json index 8850f06c9b3..6c442241445 100644 --- a/package.json +++ b/package.json @@ -137,6 +137,7 @@ "get-contrast": "^2.0.0", "get-selection-range": "^0.1.0", "globby": "^9.1.0", + "google-maps-react": "^2.0.2", "graphql": "^14.4.2", "graphql-tag": "^2.10.1", "graphql-type-json": "^0.2.1", diff --git a/packages/arch/packages/select/src/index.js b/packages/arch/packages/select/src/index.js index ebf93b3cb0f..baea0b0d0e7 100644 --- a/packages/arch/packages/select/src/index.js +++ b/packages/arch/packages/select/src/index.js @@ -2,7 +2,10 @@ import * as React from 'react'; import { useMemo } from 'react'; -import ReactSelect from 'react-select'; +import BaseSelect from 'react-select'; +import AsyncCreatableSelect from 'react-select/async-creatable'; +import AsyncSelect from 'react-select/async'; +import CreatableSelect from 'react-select/creatable'; import { colors } from '@arch-ui/theme'; // ============================== @@ -76,12 +79,42 @@ const selectStyles = { }, menuPortal: provided => ({ ...provided, zIndex: 3 }), }; -const Select = ({ innerRef, styles, ...props }: { innerRef?: React.Ref<*>, styles?: Object }) => ( - ({ ...selectStyles, ...styles }), [styles])} - {...props} - /> -); + +const getSelectVariant = ({ isAsync, isCreatable }) => { + if (isAsync && isCreatable) { + return AsyncCreatableSelect; + } + if (isAsync) { + return AsyncSelect; + } + if (isCreatable) { + return CreatableSelect; + } + + return BaseSelect; +}; + +const Select = ({ + isAsync, + isCreatable, + innerRef, + styles, + ...props +}: { + isAsync?: Boolean, + isCreatable?: Boolean, + innerRef?: React.Ref<*>, + styles?: Object, +}) => { + const ReactSelect = getSelectVariant({ isAsync, isCreatable }); + + return ( + ({ ...selectStyles, ...styles }), [styles])} + {...props} + /> + ); +}; export default Select; diff --git a/packages/fields/README.md b/packages/fields/README.md index 8357746e544..ab1900b1140 100644 --- a/packages/fields/README.md +++ b/packages/fields/README.md @@ -17,6 +17,7 @@ KeystoneJS contains a set of primitive fields types that can be imported from `@ - [File](keystone-alpha/fields/src/types/file) - [Float](keystone-alpha/fields/src/types/float) - [Integer](keystone-alpha/fields/src/types/integer) +- [Location](keystone-alpha/fields/src/types/location) - [OEmbed](keystone-alpha/fields/src/types/o-embed) - [Password](keystone-alpha/fields/src/types/password) - [Relationship](keystone-alpha/fields/src/types/relationship) diff --git a/packages/fields/package.json b/packages/fields/package.json index f5a8165ea3d..5313edf84ef 100644 --- a/packages/fields/package.json +++ b/packages/fields/package.json @@ -42,6 +42,7 @@ "cuid": "^2.1.6", "date-fns": "^1.30.1", "dumb-passwords": "^0.2.1", + "google-maps-react": "^2.0.2", "graphql": "^14.4.2", "graphql-tag": "^2.10.1", "image-extensions": "^1.1.0", @@ -64,6 +65,7 @@ "react-popper": "^1.3.3", "react-popper-tooltip": "^2.8.1", "react-select": "^3.0.4", + "react-toast-notifications": "^2.2.4", "slate": "^0.47.4", "slate-drop-or-paste-images": "^0.9.1", "slate-react": "^0.22.4", @@ -79,4 +81,4 @@ "Controller" ] } -} \ No newline at end of file +} diff --git a/packages/fields/src/index.js b/packages/fields/src/index.js index d2b640bd890..9c9c63cc2ae 100644 --- a/packages/fields/src/index.js +++ b/packages/fields/src/index.js @@ -9,6 +9,7 @@ export { default as Decimal } from './types/Decimal'; export { default as File } from './types/File'; export { default as Float } from './types/Float'; export { default as Integer } from './types/Integer'; +export { default as Location } from './types/Location'; export { default as OEmbed } from './types/OEmbed'; export { default as Password } from './types/Password'; export { default as Relationship } from './types/Relationship'; diff --git a/packages/fields/src/types/Location/Implementation.js b/packages/fields/src/types/Location/Implementation.js new file mode 100644 index 00000000000..0c0829ccce6 --- /dev/null +++ b/packages/fields/src/types/Location/Implementation.js @@ -0,0 +1,165 @@ +import { Implementation } from '../../Implementation'; +import { MongooseFieldAdapter } from '@keystone-alpha/adapter-mongoose'; +import { KnexFieldAdapter } from '@keystone-alpha/adapter-knex'; +import mongoose from 'mongoose'; + +import fetch from 'node-fetch'; + +// Disabling the getter of mongoose >= 5.1.0 +// https://github.com/Automattic/mongoose/blob/master/migrating_to_5.md#checking-if-a-path-is-populated +mongoose.set('objectIdGetter', false); + +const { + Types: { ObjectId }, +} = mongoose; + +export class Location extends Implementation { + constructor(_, { googleMapsKey }) { + super(...arguments); + this.graphQLOutputType = 'Location'; + + if (!googleMapsKey) { + throw new Error( + 'You must provide a `googleMapsKey` to Location Field. To generate a Google Maps API please visit: https://developers.google.com/maps/documentation/javascript/get-api-key' + ); + } + + this._googleMapsKey = googleMapsKey; + } + + extendAdminMeta(meta) { + return { + ...meta, + googleMapsKey: this._googleMapsKey, + }; + } + + gqlOutputFields() { + return [`${this.path}: ${this.graphQLOutputType}`]; + } + + gqlQueryInputFields() { + return [ + ...this.equalityInputFields('String'), + ...this.stringInputFields('String'), + ...this.inInputFields('String'), + ]; + } + + getGqlAuxTypes() { + return [ + ` + type ${this.graphQLOutputType} { + id: ID + googlePlaceID: String + formattedAddress: String + lat: Float + lng: Float + } + `, + ]; + } + + // Called on `User.avatar` for example + gqlOutputFieldResolvers() { + return { + [this.path]: item => { + const itemValues = item[this.path]; + if (!itemValues) { + return null; + } + return itemValues; + }, + }; + } + + async resolveInput({ resolvedData }) { + const placeId = resolvedData[this.path]; + + // NOTE: The following two conditions could easily be combined into a + // single `if (!inputId) return inputId`, but that would lose the nuance of + // returning `undefined` vs `null`. + // Premature Optimisers; be ware! + if (typeof placeId === 'undefined') { + // Nothing was passed in, so we can bail early. + return undefined; + } + + if (placeId === null) { + // `null` was specifically set, and we should set the field value to null + // To do that we... return `null` + return null; + } + + const response = await fetch( + `https://maps.googleapis.com/maps/api/geocode/json?place_id=${placeId}&key=${this._googleMapsKey}` + ).then(r => r.json()); + + if (response.results && response.results[0]) { + const { place_id, formatted_address } = response.results[0]; + const { lat, lng } = response.results[0].geometry.location; + return { + id: new ObjectId(), + googlePlaceID: place_id, + formattedAddress: formatted_address, + lat: lat, + lng: lng, + }; + } + + return null; + } + + get gqlUpdateInputFields() { + return [`${this.path}: String`]; + } + + get gqlCreateInputFields() { + return [`${this.path}: String`]; + } +} + +const CommonLocationInterface = superclass => + class extends superclass { + getQueryConditions(dbPath) { + return { + ...this.equalityConditions(dbPath), + ...this.stringConditions(dbPath), + ...this.inConditions(dbPath), + }; + } + }; + +export class MongoLocationInterface extends CommonLocationInterface(MongooseFieldAdapter) { + addToMongooseSchema(schema) { + const schemaOptions = { + type: { + id: ObjectId, + googlePlaceID: String, + formattedAddress: String, + lat: Number, + lng: Number, + }, + }; + schema.add({ [this.path]: this.mergeSchemaOptions(schemaOptions, this.config) }); + } +} + +export class KnexLocationInterface extends CommonLocationInterface(KnexFieldAdapter) { + constructor() { + super(...arguments); + + // Error rather than ignoring invalid config + // We totally can index these values, it's just not trivial. See issue #1297 + if (this.config.isUnique || this.config.isIndexed) { + throw `The Location field type doesn't support indexes on Knex. ` + + `Check the config for ${this.path} on the ${this.field.listKey} list`; + } + } + + addToTableSchema(table) { + const column = table.jsonb(this.path); + if (this.isNotNullable) column.notNullable(); + if (this.defaultTo) column.defaultTo(this.defaultTo); + } +} diff --git a/packages/fields/src/types/Location/README.md b/packages/fields/src/types/Location/README.md new file mode 100644 index 00000000000..013333e4951 --- /dev/null +++ b/packages/fields/src/types/Location/README.md @@ -0,0 +1,94 @@ + + +# Location + +The Location Field Type enables storing data from the Google Maps API. + +## Usage + +```javascript +const { Location } = require('@keystone-alpha/fields'); +const { Keystone } = require('@keystone-alpha/keystone'); + +const keystone = new Keystone(/* ... */); + +keystone.createList('Event', { + fields: { + venue: { + type: Location, + googleMapsKey: 'GOOGLE_MAPS_KEY', + }, + }, +}); +``` + +## GraphQL + +**Query** + +```graphql +query { + allEvents { + venue { + id + googlePlaceID + formattedAddress + lat + lng + } + } +} + +# Result: + +# { +# "data": { +# "allEvents": [ +# { +# "venue": { +# "id": "1", +# googlePlaceID: "ChIJOza7MD-uEmsRrf4t12uji6Y", +# "formattedAddress": "10/191 Clarence St, Sydney NSW 2000, Australia", +# "lat": -33.869374, +# "lng": 151.205097 +# } +# } +# ] +# } +# } +``` + +### Mutations + +To create a `Location`, pass the Google `place_id` for the desired field path. + +```graphql +mutation { + createEvent(data: { venue: "ChIJOza7MD-uEmsRrf4t12uji6Y" }) { + venue { + id + googlePlaceID + formattedAddress + lat + lng + } + } +} + +# Result: +# { +# "createEvent": { +# "venue": { +# "id": "1", +# googlePlaceID: "ChIJOza7MD-uEmsRrf4t12uji6Y", +# "formattedAddress": "10/191 Clarence St, Sydney NSW 2000, Australia", +# "lat": -33.869374, +# "lng": 151.205097 +# } +# } +# } +``` diff --git a/packages/fields/src/types/Location/index.js b/packages/fields/src/types/Location/index.js new file mode 100644 index 00000000000..4f401056ee5 --- /dev/null +++ b/packages/fields/src/types/Location/index.js @@ -0,0 +1,17 @@ +import { Location, MongoLocationInterface, KnexLocationInterface } from './Implementation'; +import { importView } from '@keystone-alpha/build-field-types'; + +export default { + type: 'Location', + implementation: Location, + views: { + Controller: importView('./views/Controller'), + Field: importView('./views/Field'), + Cell: importView('./views/Cell'), + Filter: importView('../Text/views/Filter'), + }, + adapters: { + mongoose: MongoLocationInterface, + knex: KnexLocationInterface, + }, +}; diff --git a/packages/fields/src/types/Location/views/Cell.js b/packages/fields/src/types/Location/views/Cell.js new file mode 100644 index 00000000000..b121b51a392 --- /dev/null +++ b/packages/fields/src/types/Location/views/Cell.js @@ -0,0 +1,11 @@ +// @flow +import type { CellProps } from '../../../types'; + +type Props = CellProps; + +const Cell = (props: Props) => { + if (!props.data) return null; + return props.data.formattedAddress; +}; + +export default Cell; diff --git a/packages/fields/src/types/Location/views/Controller.js b/packages/fields/src/types/Location/views/Controller.js new file mode 100644 index 00000000000..f53eecee439 --- /dev/null +++ b/packages/fields/src/types/Location/views/Controller.js @@ -0,0 +1,13 @@ +import FieldController from '../../../Controller'; + +export default class LocationController extends FieldController { + getQueryFragment = () => ` + ${this.path} { + id + googlePlaceID + formattedAddress + lat + lng + } + `; +} diff --git a/packages/fields/src/types/Location/views/Field.js b/packages/fields/src/types/Location/views/Field.js new file mode 100644 index 00000000000..c19495d6334 --- /dev/null +++ b/packages/fields/src/types/Location/views/Field.js @@ -0,0 +1,107 @@ +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import { useState } from 'react'; +import { useToasts } from 'react-toast-notifications'; +import { FieldContainer, FieldLabel, FieldInput } from '@arch-ui/fields'; +import Select from '@arch-ui/select'; +import { Map, Marker, GoogleApiWrapper } from 'google-maps-react'; + +const LocationField = ({ field, value: serverValue, errors, onChange, google, renderContext }) => { + const { googlePlaceID, formattedAddress, lat, lng } = serverValue || {}; + const htmlID = `ks-input-${field.path}`; + const autocompleteService = new google.maps.places.AutocompleteService(); + const geocoder = new google.maps.Geocoder(); + const { addToast } = useToasts(); + const [inputValue, setInputValue] = useState( + googlePlaceID ? { label: formattedAddress, value: googlePlaceID } : null + ); + const [marker, setMarker] = useState(lat && lng ? { lat, lng } : null); + + const handleOptionChange = option => { + if (!option) { + onChange(null); + setMarker(null); + setInputValue(null); + return; + } + + const placeId = option.value; + + geocoder.geocode({ placeId }, (results, status) => { + if (status === 'OK') { + if (results[0]) { + const { + formatted_address, + geometry: { + location: { lat, lng }, + }, + } = results[0]; + setInputValue({ label: formatted_address, value: placeId }); + setMarker({ lat: lat(), lng: lng() }); + onChange(placeId); + } + } else { + addToast('Could not find the provided location.', { + appearance: 'error', + autoDismiss: true, + }); + } + }); + }; + + const loadOptions = inputValue => + new Promise(resolve => { + autocompleteService.getPlacePredictions({ input: inputValue }, results => { + if (results) { + resolve( + results.map(({ description, place_id }) => ({ + label: description, + value: place_id, + })) + ); + } + resolve(null); + }); + }); + + const selectProps = + renderContext === 'dialog' + ? { + menuPortalTarget: document.body, + menuShouldBlockScroll: true, + } + : null; + + return ( + + + +