refactor(webapp): make group form's location select available as a separate component (#6245)

* WIP make location select a separate component

* trying ...

* fix update of geolocale from component

* clean up location select code

* refactor code

* refactor code

* clean up

* linting

* add first unit tests for location select

* reuse location setting component in user settings

* get it working

* fix unit test

* Update webapp/components/Select/LocationSelect.vue

Co-authored-by: Wolfgang Huß <wolle.huss@pjannto.com>

* Update webapp/components/Select/LocationSelect.spec.js

Co-authored-by: Wolfgang Huß <wolle.huss@pjannto.com>

* Update webapp/components/Select/LocationSelect.spec.js

Co-authored-by: Wolfgang Huß <wolle.huss@pjannto.com>

---------

Co-authored-by: Moriz Wahl <moriz.wahl@gmx.de>
Co-authored-by: ogerly <fridolin@tutanota.com>
Co-authored-by: Wolfgang Huß <wolle.huss@pjannto.com>
This commit is contained in:
mahula 2025-05-29 18:58:36 +02:00 committed by GitHub
parent ba2e086b4b
commit 51564e5d9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 198 additions and 177 deletions

View File

@ -110,29 +110,7 @@
</ds-chip>
<!-- location -->
<!-- TODO: move 'ds-select' from styleguide to main code and implement missing translation etc. functionality -->
<ds-select
id="city"
:label="$t('settings.data.labelCity') + locationNameLabelAddOnOldName"
v-model="formData.locationName"
:options="cities"
icon="map-marker"
:icon-right="null"
:placeholder="$t('settings.data.labelCity') + ' …'"
:loading="loadingGeo"
@input.native="handleCityInput"
/>
<base-button
v-if="formLocationName !== ''"
icon="close"
ghost
size="small"
style="position: relative; display: inline-block; right: -96%; top: -33px; width: 26px"
@click="formData.locationName = ''"
></base-button>
<ds-text class="location-hint" color="softer">
{{ $t('settings.data.labelCityHint') }}
</ds-text>
<location-select v-model="formData.locationName" />
<ds-space margin-top="small" />
@ -176,11 +154,9 @@ import {
} from '~/constants/groups.js'
import Editor from '~/components/Editor/Editor'
import ActionRadiusSelect from '~/components/Select/ActionRadiusSelect'
import { queryLocations } from '~/graphql/location'
import LocationSelect from '~/components/Select/LocationSelect'
import GetCategories from '~/mixins/getCategoriesMixin.js'
let timeout
export default {
name: 'GroupForm',
mixins: [GetCategories],
@ -188,6 +164,7 @@ export default {
CategoriesSelect,
Editor,
ActionRadiusSelect,
LocationSelect,
},
props: {
update: {
@ -215,12 +192,6 @@ export default {
groupType: groupType || '',
about: about || '',
description: description || '',
// from database 'locationName' comes as "string | null"
// 'formData.locationName':
// see 'created': tries to set it to a "requestGeoData" object and fills the menu if possible
// if user selects one from menu we get a "requestGeoData" object here
// "requestGeoData" object: "{ id: String, label: String, value: String }"
// otherwise it's a string: empty or none empty
locationName: locationName || '',
actionRadius: actionRadius || '',
categoryIds: categories ? categories.map((category) => category.id) : [],
@ -259,11 +230,6 @@ export default {
},
}
},
async created() {
// set to "requestGeoData" object and fill select menu if possible
this.formData.locationName =
(await this.requestGeoData(this.formLocationName)) || this.formLocationName
},
computed: {
formLocationName() {
const isNestedValue =
@ -276,9 +242,6 @@ export default {
? this.formData.locationName
: ''
},
locationNameLabelAddOnOldName() {
return this.formLocationName !== '' ? ' — ' + this.formLocationName : ''
},
descriptionLength() {
return this.$filters.removeHtml(this.formData.description).length
},
@ -322,6 +285,9 @@ export default {
changeActionRadius(event) {
this.$refs.groupForm.update('actionRadius', event.target.value)
},
changeLocation(event) {
this.formData.locationName = event.target.value
},
updateEditorDescription(value) {
this.$refs.groupForm.update('description', value)
},
@ -344,47 +310,6 @@ export default {
})
: this.$emit('createGroup', variables)
},
handleCityInput(event) {
clearTimeout(timeout)
timeout = setTimeout(
() => this.requestGeoData(event.target ? event.target.value.trim() : ''),
500,
)
},
processLocationsResult(places) {
if (!places.length) {
return []
}
const result = []
places.forEach((place) => {
result.push({
label: place.place_name,
value: place.place_name,
id: place.id,
})
})
return result
},
async requestGeoData(value) {
if (value === '') {
this.cities = []
return
}
this.loadingGeo = true
const place = encodeURIComponent(value)
const lang = this.$i18n.locale()
const {
data: { queryLocations: result },
} = await this.$apollo.query({ query: queryLocations(), variables: { place, lang } })
this.cities = this.processLocationsResult(result)
this.loadingGeo = false
return this.cities.find((city) => city.value === value)
},
},
}
</script>

View File

@ -0,0 +1,52 @@
import { mount } from '@vue/test-utils'
import LocationSelect from './LocationSelect'
const localVue = global.localVue
const propsData = { value: 'nowhere' }
let wrapper
const mocks = {
$t: jest.fn((string) => string),
$i18n: {
locale: () => 'en',
},
}
describe('LocationSelect', () => {
beforeEach(() => {})
describe('mount', () => {
const Wrapper = () => {
return mount(LocationSelect, { mocks, localVue, propsData })
}
beforeEach(() => {
wrapper = Wrapper()
})
it('renders the label', () => {
expect(wrapper.find('label.ds-input-label').exists()).toBe(true)
})
it('renders the select', () => {
expect(wrapper.find('.ds-select').exists()).toBe(true)
})
it('renders the clearLocationName button', () => {
expect(wrapper.find('.base-button').exists()).toBe(true)
})
describe('clearLocationName button click', () => {
beforeEach(() => {
wrapper.find('.base-button').trigger('click')
})
it('emits an empty string', () => {
expect(wrapper.emitted().input).toBeTruthy()
expect(wrapper.emitted().input.length).toBe(1)
expect(wrapper.emitted().input[0]).toEqual([''])
})
})
})
})

View File

@ -0,0 +1,118 @@
<template>
<div>
<label class="ds-input-label">
{{ `${$t('settings.data.labelCity')}` }}
<span v-if="locationName">{{ ` ${locationName}` }}</span>
</label>
<ds-select
id="city"
v-model="currentValue"
:options="cities"
icon="map-marker"
:icon-right="null"
:placeholder="$t('settings.data.labelCity') + ' …'"
:loading="loadingGeo"
@input.native="handleCityInput"
/>
<base-button
v-if="locationName !== ''"
icon="close"
ghost
size="small"
style="position: relative; display: inline-block; right: -94%; top: -48px; width: 29px"
@click.native="clearLocationName"
></base-button>
</div>
</template>
<script>
import { queryLocations } from '~/graphql/location'
let timeout
export default {
name: 'LocationSelect',
props: {
value: {
required: true,
},
},
async created() {
const result = await this.requestGeoData(this.locationName)
this.$nextTick(() => {
this.currentValue = result || this.locationName
})
},
data() {
return {
currentValue: this.value,
loadingGeo: false,
cities: [],
}
},
computed: {
locationName() {
return typeof this.value === 'object' ? this.value.value : this.value
},
},
watch: {
currentValue() {
if (this.currentValue !== this.value) {
this.$emit('input', this.currentValue)
}
},
value() {
if (this.value !== this.currentValue) {
this.currentValue = this.value
}
},
},
methods: {
handleCityInput(event) {
clearTimeout(timeout)
timeout = setTimeout(
() => this.requestGeoData(event.target ? event.target.value.trim() : ''),
500,
)
},
processLocationsResult(places) {
if (!places.length) {
return []
}
const result = []
places.forEach((place) => {
result.push({
label: place.place_name,
value: place.place_name,
id: place.id,
})
})
return result
},
async requestGeoData(value) {
if (value === '') {
this.cities = []
return
}
this.loadingGeo = true
const place = encodeURIComponent(value)
const lang = this.$i18n.locale()
const {
data: { queryLocations: result },
} = await this.$apollo.query({ query: queryLocations(), variables: { place, lang } })
this.cities = this.processLocationsResult(result)
this.loadingGeo = false
return this.cities.find((city) => city.value === value)
},
clearLocationName(event) {
event.target.value = ''
this.$emit('input', event.target.value)
},
},
}
</script>

View File

@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils'
import index from './index.vue'
import Vuex from 'vuex'
import LocationSelect from '~/components/Select/LocationSelect'
const localVue = global.localVue
@ -161,17 +162,7 @@ describe('index.vue', () => {
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.findComponent(LocationSelect).vm.$emit('input', 'Berlin, Germany')
wrapper.find('.ds-form').trigger('submit')
await expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
@ -204,25 +195,9 @@ describe('index.vue', () => {
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')
await wrapper.findComponent(LocationSelect).vm.$emit('input', 'Hamburg, Germany')
wrapper.find('#about').setValue('I am Peter!111elf')
wrapper.find('.ds-form').trigger('submit')

View File

@ -1,5 +1,5 @@
<template>
<ds-form class="settings-form" v-model="form" :schema="formSchema" @submit="submit">
<ds-form class="settings-form" v-model="formData" :schema="formSchema" @submit="submit">
<template #default="{ errors }">
<base-card>
<h2 class="title">{{ $t('settings.data.name') }}</h2>
@ -15,20 +15,7 @@
:placeholder="$t('settings.data.namePlaceholder')"
/>
<ds-input id="slug" model="slug" icon="at" :label="$t('settings.data.labelSlug')" />
<!-- eslint-disable vue/use-v-on-exact -->
<ds-select
id="city"
model="locationName"
icon="map-marker"
:options="cities"
:label="$t('settings.data.labelCity')"
:placeholder="$t('settings.data.labelCity')"
:loading="loadingGeo"
@input.native="handleCityInput"
/>
<ds-text class="location-hint" color="softer">
{{ $t('settings.data.labelCityHint') }}
</ds-text>
<location-select v-model="formData.locationName" />
<!-- eslint-enable vue/use-v-on-exact -->
<ds-input
id="about"
@ -49,23 +36,35 @@
<script>
import { mapGetters, mapMutations } from 'vuex'
import UniqueSlugForm from '~/components/utils/UniqueSlugForm'
import LocationSelect from '~/components/Select/LocationSelect'
import { updateUserMutation } from '~/graphql/User'
import { queryLocations } from '~/graphql/location'
import scrollToContent from './scroll-to-content.js'
let timeout
export default {
mixins: [scrollToContent],
name: 'NewsFeed',
components: {
LocationSelect,
},
data() {
return {
cities: [],
loadingData: false,
loadingGeo: false,
formData: {},
formData: {
name: '',
slug: '',
about: '',
locationName: '',
},
}
},
mounted() {
this.formData.name = this.currentUser.name
this.formData.slug = this.currentUser.slug
this.formData.about = this.currentUser.about
this.formData.locationName = this.currentUser.locationName || ''
},
computed: {
...mapGetters({
currentUser: 'auth/user',
@ -77,18 +76,10 @@ export default {
translate: this.$t,
})
return {
name: { required: true, min: 3 },
...uniqueSlugForm.formSchema,
}
},
form: {
get: function () {
const { name, slug, locationName, about } = this.currentUser
return { name, slug, locationName, about }
},
set: function (formData) {
this.formData = formData
},
},
},
methods: {
...mapMutations({
@ -123,52 +114,12 @@ export default {
this.loadingData = false
}
},
handleCityInput(value) {
clearTimeout(timeout)
timeout = setTimeout(() => this.requestGeoData(value), 500)
},
processLocationsResult(places) {
if (!places.length) {
return []
}
const result = []
places.forEach((place) => {
result.push({
label: place.place_name,
value: place.place_name,
id: place.id,
})
})
return result
},
async requestGeoData(e) {
const value = e.target ? e.target.value.trim() : ''
if (value === '') {
this.cities = []
return
}
this.loadingGeo = true
const place = encodeURIComponent(value)
const lang = this.$i18n.locale()
const {
data: { queryLocations: res },
} = await this.$apollo.query({ query: queryLocations(), variables: { place, lang } })
this.cities = this.processLocationsResult(res)
this.loadingGeo = false
},
},
}
</script>
<style lang="scss">
// .settings-form {
// >
.location-hint {
margin-top: -$space-x-small - $space-xxx-small - $space-xxx-small;
}
// }
</style>