Merge pull request #4185 from Ocelot-Social-Community/fix_locations

Fix locations
This commit is contained in:
Ulf Gebhardt 2021-02-04 18:54:41 +01:00 committed by GitHub
commit 49f7897689
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 343 additions and 38 deletions

View File

@ -121,6 +121,7 @@ export default shield(
userData: isAuthenticated, userData: isAuthenticated,
MyInviteCodes: isAuthenticated, MyInviteCodes: isAuthenticated,
isValidInviteCode: allow, isValidInviteCode: allow,
queryLocations: isAuthenticated,
}, },
Mutation: { Mutation: {
'*': deny, '*': deny,

View File

@ -1,4 +1,6 @@
import { UserInputError } from 'apollo-server'
import Resolver from './helpers/Resolver' import Resolver from './helpers/Resolver'
import { queryLocations } from './users/location'
export default { export default {
Location: { 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)
}
},
},
} }

View File

@ -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 export default createOrUpdateLocations

View File

@ -6,7 +6,7 @@ import createServer from '../../../server'
const neode = getNeode() const neode = getNeode()
const driver = getDriver() const driver = getDriver()
let authenticatedUser, mutate, variables let authenticatedUser, mutate, query, variables
const updateUserMutation = gql` const updateUserMutation = gql`
mutation($id: ID!, $name: String!, $locationName: String) { 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 = [ const newlyCreatedNodesWithLocales = [
{ {
city: { city: {
@ -76,6 +85,7 @@ beforeAll(() => {
}, },
}) })
mutate = createTestClient(server).mutate mutate = createTestClient(server).mutate
query = createTestClient(server).query
}) })
beforeEach(() => { beforeEach(() => {
@ -85,6 +95,66 @@ beforeEach(() => {
afterEach(cleanDatabase) 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('userMiddleware', () => {
describe('UpdateUser', () => { describe('UpdateUser', () => {
let user let user
@ -95,7 +165,7 @@ describe('userMiddleware', () => {
authenticatedUser = await user.toJson() 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 = {
...variables, ...variables,
id: 'updating-user', id: 'updating-user',

View File

@ -16,3 +16,13 @@ type Location {
parent: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") 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]!
}

View File

@ -1,4 +1,3 @@
MAPBOX_TOKEN="pk.eyJ1IjoiYnVzZmFrdG9yIiwiYSI6ImNraDNiM3JxcDBhaWQydG1uczhpZWtpOW4ifQ.7TNRTO-o9aK1Y6MyW_Nd4g"
SENTRY_DSN_WEBAPP= SENTRY_DSN_WEBAPP=
COMMIT= COMMIT=
PUBLIC_REGISTRATION=false PUBLIC_REGISTRATION=false

View File

@ -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
}
}
`

View File

@ -11,6 +11,7 @@ describe('index.vue', () => {
beforeEach(() => { beforeEach(() => {
mocks = { mocks = {
$i18n: { locale: () => 'en' },
$t: jest.fn(), $t: jest.fn(),
$apollo: { $apollo: {
mutate: jest 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: { $toast: {
error: jest.fn(), error: jest.fn(),
@ -93,9 +128,182 @@ describe('index.vue', () => {
wrapper.find('#name').setValue('Peter') wrapper.find('#name').setValue('Peter')
wrapper.find('.ds-form').trigger('submit') 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)
})
}) })
}) })
}) })

View File

@ -24,7 +24,7 @@
/> />
<!-- eslint-enable vue/use-v-on-exact --> <!-- eslint-enable vue/use-v-on-exact -->
<ds-input <ds-input
id="bio" id="about"
model="about" model="about"
type="textarea" type="textarea"
rows="3" rows="3"
@ -41,17 +41,15 @@
<script> <script>
import { mapGetters, mapMutations } from 'vuex' import { mapGetters, mapMutations } from 'vuex'
import { CancelToken } from 'axios'
import UniqueSlugForm from '~/components/utils/UniqueSlugForm' import UniqueSlugForm from '~/components/utils/UniqueSlugForm'
import { updateUserMutation } from '~/graphql/User' import { updateUserMutation } from '~/graphql/User'
import { queryLocations } from '~/graphql/location'
let timeout let timeout
const mapboxToken = process.env.MAPBOX_TOKEN
export default { export default {
data() { data() {
return { return {
axiosSource: null,
cities: [], cities: [],
loadingData: false, loadingData: false,
loadingGeo: false, loadingGeo: false,
@ -123,51 +121,38 @@ export default {
clearTimeout(timeout) clearTimeout(timeout)
timeout = setTimeout(() => this.requestGeoData(value), 500) timeout = setTimeout(() => this.requestGeoData(value), 500)
}, },
processCityResults(res) { processLocationsResult(places) {
if (!res || !res.data || !res.data.features || !res.data.features.length) { if (!places.length) {
return [] return []
} }
const output = [] const result = []
res.data.features.forEach((item) => { places.forEach((place) => {
output.push({ result.push({
label: item.place_name, label: place.place_name,
value: item.place_name, value: place.place_name,
id: item.id, id: place.id,
}) })
}) })
return output return result
}, },
requestGeoData(e) { async requestGeoData(e) {
if (this.axiosSource) {
// cancel last request
this.axiosSource.cancel()
}
const value = e.target ? e.target.value.trim() : '' const value = e.target ? e.target.value.trim() : ''
if (value === '' || value.length < 3) { if (value === '') {
this.cities = [] this.cities = []
return return
} }
this.loadingGeo = true this.loadingGeo = true
this.axiosSource = CancelToken.source()
const place = encodeURIComponent(value) const place = encodeURIComponent(value)
const lang = this.$i18n.locale() const lang = this.$i18n.locale()
this.$axios const {
.get( data: { queryLocations: res },
`https://api.mapbox.com/geocoding/v5/mapbox.places/${place}.json?access_token=${mapboxToken}&types=region,place,country&language=${lang}`, } = await this.$apollo.query({ query: queryLocations(), variables: { place, lang } })
{
cancelToken: this.axiosSource.token, this.cities = this.processLocationsResult(res)
}, this.loadingGeo = false
)
.then((res) => {
this.cities = this.processCityResults(res)
})
.finally(() => {
this.loadingGeo = false
})
}, },
}, },
} }