diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 6fe82951e..dae6f0e48 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -121,6 +121,7 @@ export default shield( userData: isAuthenticated, MyInviteCodes: isAuthenticated, isValidInviteCode: allow, + queryLocations: isAuthenticated, }, Mutation: { '*': deny, diff --git a/backend/src/schema/resolvers/locations.js b/backend/src/schema/resolvers/locations.js index be72001f7..fa0feafa1 100644 --- a/backend/src/schema/resolvers/locations.js +++ b/backend/src/schema/resolvers/locations.js @@ -1,4 +1,6 @@ +import { UserInputError } from 'apollo-server' import Resolver from './helpers/Resolver' +import { queryLocations } from './users/location' export default { Location: { @@ -16,4 +18,13 @@ export default { ], }), }, + Query: { + queryLocations: async (object, args, context, resolveInfo) => { + try { + return queryLocations(args) + } catch (e) { + throw new UserInputError(e.message) + } + }, + }, } diff --git a/backend/src/schema/resolvers/users/location.js b/backend/src/schema/resolvers/users/location.js index b58d8d1aa..affd3267e 100644 --- a/backend/src/schema/resolvers/users/location.js +++ b/backend/src/schema/resolvers/users/location.js @@ -137,4 +137,15 @@ const createOrUpdateLocations = async (userId, locationName, session) => { }) } +export const queryLocations = async ({ place, lang }) => { + const res = await fetch( + `https://api.mapbox.com/geocoding/v5/mapbox.places/${place}.json?access_token=${CONFIG.MAPBOX_TOKEN}&types=region,place,country&language=${lang}`, + ) + // Return empty array if no location found or error occurred + if (!res || !res.features) { + return [] + } + return res.features +} + export default createOrUpdateLocations diff --git a/backend/src/schema/resolvers/users/location.spec.js b/backend/src/schema/resolvers/users/location.spec.js index 3044e4b6f..0c871b168 100644 --- a/backend/src/schema/resolvers/users/location.spec.js +++ b/backend/src/schema/resolvers/users/location.spec.js @@ -6,7 +6,7 @@ import createServer from '../../../server' const neode = getNeode() const driver = getDriver() -let authenticatedUser, mutate, variables +let authenticatedUser, mutate, query, variables const updateUserMutation = gql` mutation($id: ID!, $name: String!, $locationName: String) { @@ -16,6 +16,15 @@ const updateUserMutation = gql` } ` +const queryLocations = gql` + query($place: String!, $lang: String!) { + queryLocations(place: $place, lang: $lang) { + place_name + id + } + } +` + const newlyCreatedNodesWithLocales = [ { city: { @@ -76,6 +85,7 @@ beforeAll(() => { }, }) mutate = createTestClient(server).mutate + query = createTestClient(server).query }) beforeEach(() => { @@ -85,6 +95,66 @@ beforeEach(() => { afterEach(cleanDatabase) +describe('Location Service', () => { + // Authentication + // TODO: unify, externalize, simplify, wtf? + let user + beforeEach(async () => { + user = await Factory.build('user', { + id: 'location-user', + }) + authenticatedUser = await user.toJson() + }) + + it('query Location existing', async () => { + variables = { + place: 'Berlin', + lang: 'en', + } + const result = await query({ query: queryLocations, variables }) + expect(result.data.queryLocations).toEqual([ + { id: 'place.14094307404564380', place_name: 'Berlin, Germany' }, + { id: 'place.15095411613564380', place_name: 'Berlin, Maryland, United States' }, + { id: 'place.5225018734564380', place_name: 'Berlin, Connecticut, United States' }, + { id: 'place.16922023226564380', place_name: 'Berlin, New Jersey, United States' }, + { id: 'place.4035845612564380', place_name: 'Berlin Township, New Jersey, United States' }, + ]) + }) + + it('query Location existing in different language', async () => { + variables = { + place: 'Berlin', + lang: 'de', + } + const result = await query({ query: queryLocations, variables }) + expect(result.data.queryLocations).toEqual([ + { id: 'place.14094307404564380', place_name: 'Berlin, Deutschland' }, + { id: 'place.15095411613564380', place_name: 'Berlin, Maryland, Vereinigte Staaten' }, + { id: 'place.16922023226564380', place_name: 'Berlin, New Jersey, Vereinigte Staaten' }, + { id: 'place.10735893248465990', place_name: 'Berlin Heights, Ohio, Vereinigte Staaten' }, + { id: 'place.1165756679564380', place_name: 'Berlin, Massachusetts, Vereinigte Staaten' }, + ]) + }) + + it('query Location not existing', async () => { + variables = { + place: 'GbHtsd4sdHa', + lang: 'en', + } + const result = await query({ query: queryLocations, variables }) + expect(result.data.queryLocations).toEqual([]) + }) + + it('query Location without a place name given', async () => { + variables = { + place: '', + lang: 'en', + } + const result = await query({ query: queryLocations, variables }) + expect(result.data.queryLocations).toEqual([]) + }) +}) + describe('userMiddleware', () => { describe('UpdateUser', () => { let user @@ -95,7 +165,7 @@ describe('userMiddleware', () => { authenticatedUser = await user.toJson() }) - it('creates a Location node with localised city/state/country names', async () => { + it('creates a Location node with localized city/state/country names', async () => { variables = { ...variables, id: 'updating-user', diff --git a/backend/src/schema/types/Location.gql b/backend/src/schema/types/type/Location.gql similarity index 58% rename from backend/src/schema/types/Location.gql rename to backend/src/schema/types/type/Location.gql index 78bc07656..fad24cc26 100644 --- a/backend/src/schema/types/Location.gql +++ b/backend/src/schema/types/type/Location.gql @@ -16,3 +16,13 @@ type Location { parent: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") } +# This is not smart - we need one location for everything - use the same type everywhere! +type LocationMapBox { + id: ID! + place_name: String! +} + +type Query { + queryLocations(place: String!, lang: String!): [LocationMapBox]! +} + diff --git a/webapp/.env.template b/webapp/.env.template index 198452e27..1acad49b4 100644 --- a/webapp/.env.template +++ b/webapp/.env.template @@ -1,4 +1,3 @@ -MAPBOX_TOKEN="pk.eyJ1IjoiYnVzZmFrdG9yIiwiYSI6ImNraDNiM3JxcDBhaWQydG1uczhpZWtpOW4ifQ.7TNRTO-o9aK1Y6MyW_Nd4g" SENTRY_DSN_WEBAPP= COMMIT= PUBLIC_REGISTRATION=false diff --git a/webapp/graphql/location.js b/webapp/graphql/location.js new file mode 100644 index 000000000..0fa2c2a2d --- /dev/null +++ b/webapp/graphql/location.js @@ -0,0 +1,10 @@ +import gql from 'graphql-tag' + +export const queryLocations = () => gql` + query($place: String!, $lang: String!) { + queryLocations(place: $place, lang: $lang) { + place_name + id + } + } +` diff --git a/webapp/pages/settings/index.spec.js b/webapp/pages/settings/index.spec.js index ff2781073..01d68e029 100644 --- a/webapp/pages/settings/index.spec.js +++ b/webapp/pages/settings/index.spec.js @@ -11,6 +11,7 @@ describe('index.vue', () => { beforeEach(() => { mocks = { + $i18n: { locale: () => 'en' }, $t: jest.fn(), $apollo: { mutate: jest @@ -27,6 +28,40 @@ describe('index.vue', () => { }, }, }), + query: jest + .fn() + .mockRejectedValue({ message: 'Ouch!' }) + .mockResolvedValueOnce({ + data: { + queryLocations: [ + { + place_name: 'Brazil', + id: 'country.9531777110682710', + __typename: 'LocationMapBox', + }, + { + place_name: 'United Kingdom', + id: 'country.12405201072814600', + __typename: 'LocationMapBox', + }, + { + place_name: 'Buenos Aires, Argentina', + id: 'place.7159025980072860', + __typename: 'LocationMapBox', + }, + { + place_name: 'Bandung, West Java, Indonesia', + id: 'place.8224726664248590', + __typename: 'LocationMapBox', + }, + { + place_name: 'Banten, Indonesia', + id: 'region.11849645724544000', + __typename: 'LocaLocationMapBoxtion2', + }, + ], + }, + }), }, $toast: { error: jest.fn(), @@ -93,9 +128,182 @@ describe('index.vue', () => { wrapper.find('#name').setValue('Peter') wrapper.find('.ds-form').trigger('submit') - expect(mocks.$apollo.mutate).toHaveBeenCalled() + expect(mocks.$apollo.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + name: 'Peter', + }), + }), + ) }) }) + + describe('given a new slug and hitting submit', () => { + it('calls updateUser mutation', () => { + const wrapper = Wrapper() + + wrapper.find('#slug').setValue('peter-der-lustige') + wrapper.find('.ds-form').trigger('submit') + + expect(mocks.$apollo.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + slug: 'peter-der-lustige', + }), + }), + ) + }) + }) + + describe('given a new location and hitting submit', () => { + it('calls updateUser mutation', async () => { + const wrapper = Wrapper() + wrapper.setData({ + cities: [ + { + label: 'Berlin, Germany', + value: 'Berlin, Germany', + id: '1', + }, + ], + }) + await wrapper.vm.$nextTick() + wrapper.find('.ds-select-option').trigger('click') + wrapper.find('.ds-form').trigger('submit') + + await expect(mocks.$apollo.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + locationName: 'Berlin, Germany', + }), + }), + ) + }) + }) + + describe('given a new about and hitting submit', () => { + it('calls updateUser mutation', () => { + const wrapper = Wrapper() + + wrapper.find('#about').setValue('I am Peter!111elf') + wrapper.find('.ds-form').trigger('submit') + + expect(mocks.$apollo.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + about: 'I am Peter!111elf', + }), + }), + ) + }) + }) + + describe('given new username, slug, location and about then hitting submit', () => { + it('calls updateUser mutation', async () => { + const wrapper = Wrapper() + + wrapper.setData({ + cities: [ + { + label: 'Berlin, Germany', + value: 'Berlin, Germany', + id: '1', + }, + { + label: 'Hamburg, Germany', + value: 'Hamburg, Germany', + id: '2', + }, + ], + }) + await wrapper.vm.$nextTick() + wrapper.find('#name').setValue('Peter') + wrapper.find('#slug').setValue('peter-der-lustige') + wrapper.findAll('.ds-select-option').at(1).trigger('click') + wrapper.find('#about').setValue('I am Peter!111elf') + wrapper.find('.ds-form').trigger('submit') + + await expect(mocks.$apollo.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + name: 'Peter', + slug: 'peter-der-lustige', + locationName: 'Hamburg, Germany', + about: 'I am Peter!111elf', + }), + }), + ) + }) + }) + }) + + describe('given user input on location field', () => { + it('calls queryLocations query', async () => { + const wrapper = Wrapper() + + jest.useFakeTimers() + + wrapper.find('#city').trigger('input') + wrapper.find('#city').setValue('B') + + jest.runAllTimers() + + expect(mocks.$apollo.query).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + place: 'B', + }), + }), + ) + }) + + it('opens the dropdown', () => { + const wrapper = Wrapper() + + wrapper.find('#city').trigger('input') + wrapper.find('#city').setValue('B') + + expect(wrapper.find('.ds-select-dropdown').isVisible()).toBe(true) + }) + }) + + describe('given no user input on location field', () => { + it('cannot call queryLocations query', async () => { + const wrapper = Wrapper() + + jest.useFakeTimers() + + wrapper.find('#city').setValue('') + wrapper.find('#city').trigger('input') + + jest.runAllTimers() + + expect(mocks.$apollo.query).not.toHaveBeenCalled() + }) + + it('does not show the dropdown', () => { + const wrapper = Wrapper() + + wrapper.find('#city').setValue('') + wrapper.find('#city').trigger('input') + + expect(wrapper.find('.ds-select-is-open').exists()).toBe(false) + }) + }) + + describe('given user presses escape on location field', () => { + it('closes the dropdown', () => { + const wrapper = Wrapper() + + wrapper.find('#city').setValue('B') + wrapper.find('#city').trigger('input') + + expect(wrapper.find('.ds-select-dropdown').isVisible()).toBe(true) + + wrapper.find('#city').trigger('keyup.esc') + + expect(wrapper.find('.ds-select-is-open').exists()).toBe(false) + }) }) }) }) diff --git a/webapp/pages/settings/index.vue b/webapp/pages/settings/index.vue index 9766ab81c..5d59f5ecb 100644 --- a/webapp/pages/settings/index.vue +++ b/webapp/pages/settings/index.vue @@ -24,7 +24,7 @@ /> import { mapGetters, mapMutations } from 'vuex' -import { CancelToken } from 'axios' import UniqueSlugForm from '~/components/utils/UniqueSlugForm' import { updateUserMutation } from '~/graphql/User' +import { queryLocations } from '~/graphql/location' let timeout -const mapboxToken = process.env.MAPBOX_TOKEN export default { data() { return { - axiosSource: null, cities: [], loadingData: false, loadingGeo: false, @@ -123,51 +121,38 @@ export default { clearTimeout(timeout) timeout = setTimeout(() => this.requestGeoData(value), 500) }, - processCityResults(res) { - if (!res || !res.data || !res.data.features || !res.data.features.length) { + processLocationsResult(places) { + if (!places.length) { return [] } - const output = [] - res.data.features.forEach((item) => { - output.push({ - label: item.place_name, - value: item.place_name, - id: item.id, + const result = [] + places.forEach((place) => { + result.push({ + label: place.place_name, + value: place.place_name, + id: place.id, }) }) - return output + return result }, - requestGeoData(e) { - if (this.axiosSource) { - // cancel last request - this.axiosSource.cancel() - } - + async requestGeoData(e) { const value = e.target ? e.target.value.trim() : '' - if (value === '' || value.length < 3) { + if (value === '') { this.cities = [] return } this.loadingGeo = true - this.axiosSource = CancelToken.source() const place = encodeURIComponent(value) const lang = this.$i18n.locale() - this.$axios - .get( - `https://api.mapbox.com/geocoding/v5/mapbox.places/${place}.json?access_token=${mapboxToken}&types=region,place,country&language=${lang}`, - { - cancelToken: this.axiosSource.token, - }, - ) - .then((res) => { - this.cities = this.processCityResults(res) - }) - .finally(() => { - this.loadingGeo = false - }) + const { + data: { queryLocations: res }, + } = await this.$apollo.query({ query: queryLocations(), variables: { place, lang } }) + + this.cities = this.processLocationsResult(res) + this.loadingGeo = false }, }, }