Refactor addressautocomplete components into a mixin

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2021-09-07 17:40:31 +02:00
parent 8a58f5ba7c
commit bc1f71e742
No known key found for this signature in database
GPG key ID: A061B9DDE0CA0773
3 changed files with 228 additions and 297 deletions

View file

@ -11,6 +11,7 @@
icon="map-marker" icon="map-marker"
expanded expanded
@select="updateSelected" @select="updateSelected"
v-bind="$attrs"
> >
<template #default="{ option }"> <template #default="{ option }">
<b-icon :icon="option.poiInfos.poiIcon.icon" /> <b-icon :icon="option.poiInfos.poiIcon.icon" />
@ -20,7 +21,11 @@
</template> </template>
</b-autocomplete> </b-autocomplete>
</b-field> </b-field>
<b-field v-if="canDoGeoLocation"> <b-field
v-if="canDoGeoLocation"
:message="fieldErrors"
:type="{ 'is-danger': fieldErrors.length }"
>
<b-button <b-button
type="is-text" type="is-text"
v-if="!gettingLocation" v-if="!gettingLocation"
@ -52,26 +57,16 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator"; import { Component, Mixins, Prop, Watch } from "vue-property-decorator";
import { LatLng } from "leaflet";
import debounce from "lodash/debounce";
import { DebouncedFunc } from "lodash";
import { Address, IAddress } from "../../types/address.model"; import { Address, IAddress } from "../../types/address.model";
import { ADDRESS, REVERSE_GEOCODE } from "../../graphql/address"; import AddressAutoCompleteMixin from "@/mixins/AddressAutoCompleteMixin";
import { CONFIG } from "../../graphql/config";
import { IConfig } from "../../types/config.model";
@Component({ @Component({
components: { inheritAttrs: false,
"map-leaflet": () =>
import(/* webpackChunkName: "map" */ "@/components/Map.vue"),
},
apollo: {
config: CONFIG,
},
}) })
export default class AddressAutoComplete extends Vue { export default class AddressAutoComplete extends Mixins(
@Prop({ required: true }) value!: IAddress; AddressAutoCompleteMixin
) {
@Prop({ required: false, default: false }) type!: string | false; @Prop({ required: false, default: false }) type!: string | false;
@Prop({ required: false, default: true, type: Boolean }) @Prop({ required: false, default: true, type: Boolean })
doGeoLocation!: boolean; doGeoLocation!: boolean;
@ -80,84 +75,20 @@ export default class AddressAutoComplete extends Vue {
selected: IAddress = new Address(); selected: IAddress = new Address();
isFetching = false;
initialQueryText = ""; initialQueryText = "";
addressModalActive = false; addressModalActive = false;
showmap = false; showmap = false;
private gettingLocation = false; get queryText2(): string {
// eslint-disable-next-line no-undef
private location!: GeolocationPosition;
private gettingLocationError: any;
private mapDefaultZoom = 15;
config!: IConfig;
fetchAsyncData!: DebouncedFunc<(query: string) => Promise<void>>;
// We put this in data because of issues like
// https://github.com/vuejs/vue-class-component/issues/263
data(): Record<string, unknown> {
return {
fetchAsyncData: debounce(this.asyncData, 200),
};
}
async asyncData(query: string): Promise<void> {
if (!query.length) {
this.addressData = [];
this.selected = new Address();
return;
}
if (query.length < 3) {
this.addressData = [];
return;
}
this.isFetching = true;
const variables: { query: string; locale: string; type?: string } = {
query,
locale: this.$i18n.locale,
};
if (this.type) {
variables.type = this.type;
}
const result = await this.$apollo.query({
query: ADDRESS,
fetchPolicy: "network-only",
variables,
});
this.addressData = result.data.searchAddress.map(
(address: IAddress) => new Address(address)
);
this.isFetching = false;
}
@Watch("config")
watchConfig(config: IConfig): void {
if (!config.geocoding.autocomplete) {
// If autocomplete is disabled, we put a larger debounce value
// so that we don't request with incomplete address
this.fetchAsyncData = debounce(this.asyncData, 2000);
}
}
get queryText(): string {
if (this.value !== undefined) { if (this.value !== undefined) {
return new Address(this.value).fullName; return new Address(this.value).fullName;
} }
return this.initialQueryText; return this.initialQueryText;
} }
set queryText(queryText: string) { set queryText2(queryText: string) {
this.initialQueryText = queryText; this.initialQueryText = queryText;
} }
@ -186,80 +117,6 @@ export default class AddressAutoComplete extends Vue {
this.showmap = !this.showmap; this.showmap = !this.showmap;
} }
async reverseGeoCode(e: LatLng, zoom: number): Promise<void> {
// If the position has been updated through autocomplete selection, no need to geocode it!
if (this.checkCurrentPosition(e)) return;
const result = await this.$apollo.query({
query: REVERSE_GEOCODE,
variables: {
latitude: e.lat,
longitude: e.lng,
zoom,
locale: this.$i18n.locale,
},
});
this.addressData = result.data.reverseGeocode.map(
(address: IAddress) => new Address(address)
);
if (this.addressData.length > 0) {
const defaultAddress = new Address(this.addressData[0]);
this.selected = defaultAddress;
this.$emit("input", this.selected);
this.queryText = `${defaultAddress.poiInfos.name} ${defaultAddress.poiInfos.alternativeName}`;
}
}
checkCurrentPosition(e: LatLng): boolean {
if (!this.selected || !this.selected.geom) return false;
const lat = parseFloat(this.selected.geom.split(";")[1]);
const lon = parseFloat(this.selected.geom.split(";")[0]);
return e.lat === lat && e.lng === lon;
}
async locateMe(): Promise<void> {
this.gettingLocation = true;
try {
this.location = await AddressAutoComplete.getLocation();
this.mapDefaultZoom = 12;
this.reverseGeoCode(
new LatLng(
this.location.coords.latitude,
this.location.coords.longitude
),
12
);
} catch (e) {
console.error(e);
this.gettingLocationError = e.message;
}
this.gettingLocation = false;
}
// eslint-disable-next-line no-undef
static async getLocation(): Promise<GeolocationPosition> {
return new Promise((resolve, reject) => {
if (!("geolocation" in navigator)) {
reject(new Error("Geolocation is not available."));
}
navigator.geolocation.getCurrentPosition(
(pos) => {
resolve(pos);
},
(err) => {
reject(err);
}
);
});
}
// eslint-disable-next-line class-methods-use-this
get isSecureContext(): boolean {
return window.isSecureContext;
}
get canDoGeoLocation(): boolean { get canDoGeoLocation(): boolean {
return this.isSecureContext && this.doGeoLocation; return this.isSecureContext && this.doGeoLocation;
} }

View file

@ -1,6 +1,11 @@
<template> <template>
<div class="address-autocomplete"> <div class="address-autocomplete">
<b-field expanded> <b-field
:label-for="id"
expanded
:message="fieldErrors"
:type="{ 'is-danger': fieldErrors.length }"
>
<template slot="label"> <template slot="label">
{{ actualLabel }} {{ actualLabel }}
<b-button <b-button
@ -8,8 +13,13 @@
size="is-small" size="is-small"
icon-right="map-marker" icon-right="map-marker"
@click="locateMe" @click="locateMe"
:title="$t('Use my location')"
/> />
<span v-else-if="gettingLocation">{{ $t("Getting location") }}</span> <span
class="is-size-6 has-text-weight-normal"
v-else-if="gettingLocation"
>{{ $t("Getting location") }}</span
>
</template> </template>
<b-autocomplete <b-autocomplete
:data="addressData" :data="addressData"
@ -21,6 +31,8 @@
icon="map-marker" icon="map-marker"
expanded expanded
@select="updateSelected" @select="updateSelected"
v-bind="$attrs"
:id="id"
> >
<template #default="{ option }"> <template #default="{ option }">
<b-icon :icon="option.poiInfos.poiIcon.icon" /> <b-icon :icon="option.poiInfos.poiIcon.icon" />
@ -51,6 +63,7 @@
@click="resetAddress" @click="resetAddress"
class="reset-area" class="reset-area"
icon-left="close" icon-left="close"
:title="$t('Clear address field')"
/> />
</b-field> </b-field>
<div class="map" v-if="selected && selected.geom && selected.poiInfos"> <div class="map" v-if="selected && selected.geom && selected.poiInfos">
@ -109,95 +122,29 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator"; import { Component, Prop, Watch, Mixins } from "vue-property-decorator";
import { LatLng } from "leaflet"; import { LatLng } from "leaflet";
import debounce from "lodash/debounce";
import { DebouncedFunc } from "lodash";
import { Address, IAddress } from "../../types/address.model"; import { Address, IAddress } from "../../types/address.model";
import { ADDRESS, REVERSE_GEOCODE } from "../../graphql/address"; import AddressAutoCompleteMixin from "../../mixins/AddressAutoCompleteMixin";
import { CONFIG } from "../../graphql/config";
import { IConfig } from "../../types/config.model";
@Component({ @Component({
components: { inheritAttrs: false,
"map-leaflet": () =>
import(/* webpackChunkName: "map" */ "@/components/Map.vue"),
},
apollo: {
config: CONFIG,
},
}) })
export default class FullAddressAutoComplete extends Vue { export default class FullAddressAutoComplete extends Mixins(
@Prop({ required: true }) value!: IAddress; AddressAutoCompleteMixin
) {
@Prop({ required: false, default: "" }) label!: string; @Prop({ required: false, default: "" }) label!: string;
addressData: IAddress[] = [];
selected: IAddress = new Address();
isFetching = false;
queryText: string = (this.value && new Address(this.value).fullName) || "";
addressModalActive = false; addressModalActive = false;
private gettingLocation = false; private static componentId = 0;
// eslint-disable-next-line no-undef created(): void {
private location!: GeolocationPosition; FullAddressAutoComplete.componentId += 1;
private gettingLocationError: any;
private mapDefaultZoom = 15;
config!: IConfig;
fetchAsyncData!: DebouncedFunc<(query: string) => Promise<void>>;
// We put this in data because of issues like
// https://github.com/vuejs/vue-class-component/issues/263
data(): Record<string, unknown> {
return {
fetchAsyncData: debounce(this.asyncData, 200),
};
} }
async asyncData(query: string): Promise<void> { get id(): string {
if (!query.length) { return `full-address-autocomplete-${FullAddressAutoComplete.componentId}`;
this.addressData = [];
this.selected = new Address();
return;
}
if (query.length < 3) {
this.addressData = [];
return;
}
this.isFetching = true;
const result = await this.$apollo.query({
query: ADDRESS,
fetchPolicy: "network-only",
variables: {
query,
locale: this.$i18n.locale,
},
});
this.addressData = result.data.searchAddress.map(
(address: IAddress) => new Address(address)
);
this.isFetching = false;
}
@Watch("config")
watchConfig(config: IConfig): void {
if (!config.geocoding.autocomplete) {
// If autocomplete is disabled, we put a larger debounce value
// so that we don't request with incomplete address
this.fetchAsyncData = debounce(this.asyncData, 2000);
}
} }
@Watch("value") @Watch("value")
@ -225,30 +172,6 @@ export default class FullAddressAutoComplete extends Vue {
this.addressModalActive = true; this.addressModalActive = true;
} }
async reverseGeoCode(e: LatLng, zoom: number): Promise<void> {
// If the position has been updated through autocomplete selection, no need to geocode it!
if (this.checkCurrentPosition(e)) return;
const result = await this.$apollo.query({
query: REVERSE_GEOCODE,
variables: {
latitude: e.lat,
longitude: e.lng,
zoom,
locale: this.$i18n.locale,
},
});
this.addressData = result.data.reverseGeocode.map(
(address: IAddress) => new Address(address)
);
if (this.addressData.length > 0) {
const defaultAddress = new Address(this.addressData[0]);
this.selected = defaultAddress;
this.$emit("input", this.selected);
this.queryText = `${defaultAddress.poiInfos.name} ${defaultAddress.poiInfos.alternativeName}`;
}
}
checkCurrentPosition(e: LatLng): boolean { checkCurrentPosition(e: LatLng): boolean {
if (!this.selected || !this.selected.geom) return false; if (!this.selected || !this.selected.geom) return false;
const lat = parseFloat(this.selected.geom.split(";")[1]); const lat = parseFloat(this.selected.geom.split(";")[1]);
@ -257,25 +180,6 @@ export default class FullAddressAutoComplete extends Vue {
return e.lat === lat && e.lng === lon; return e.lat === lat && e.lng === lon;
} }
async locateMe(): Promise<void> {
this.gettingLocation = true;
try {
this.gettingLocation = false;
this.location = await FullAddressAutoComplete.getLocation();
this.mapDefaultZoom = 12;
this.reverseGeoCode(
new LatLng(
this.location.coords.latitude,
this.location.coords.longitude
),
12
);
} catch (e) {
this.gettingLocation = false;
this.gettingLocationError = e.message;
}
}
get actualLabel(): string { get actualLabel(): string {
return this.label || (this.$t("Find an address") as string); return this.label || (this.$t("Find an address") as string);
} }
@ -285,24 +189,6 @@ export default class FullAddressAutoComplete extends Vue {
return window.isSecureContext; return window.isSecureContext;
} }
// eslint-disable-next-line no-undef
static async getLocation(): Promise<GeolocationPosition> {
return new Promise((resolve, reject) => {
if (!("geolocation" in navigator)) {
reject(new Error("Geolocation is not available."));
}
navigator.geolocation.getCurrentPosition(
(pos) => {
resolve(pos);
},
(err) => {
reject(err);
}
);
});
}
@Watch("queryText") @Watch("queryText")
resetAddressOnEmptyField(queryText: string): void { resetAddressOnEmptyField(queryText: string): void {
if (queryText === "" && this.selected?.id) { if (queryText === "" && this.selected?.id) {

View file

@ -0,0 +1,188 @@
import { mixins } from "vue-class-component";
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { LatLng } from "leaflet";
import { Address, IAddress } from "../types/address.model";
import { ADDRESS, REVERSE_GEOCODE } from "../graphql/address";
import { CONFIG } from "../graphql/config";
import { IConfig } from "../types/config.model";
import debounce from "lodash/debounce";
import { DebouncedFunc } from "lodash";
@Component({
components: {
"map-leaflet": () =>
import(/* webpackChunkName: "map" */ "@/components/Map.vue"),
},
apollo: {
config: CONFIG,
},
})
export default class AddressAutoCompleteMixin extends mixins(Vue) {
@Prop({ required: true }) value!: IAddress;
gettingLocationError: string | null = null;
gettingLocation = false;
mapDefaultZoom = 15;
addressData: IAddress[] = [];
selected: IAddress = new Address();
queryText: string = (this.value && new Address(this.value).fullName) || "";
config!: IConfig;
isFetching = false;
fetchAsyncData!: DebouncedFunc<(query: string) => Promise<void>>;
// eslint-disable-next-line no-undef
protected location!: GeolocationPosition;
// We put this in data because of issues like
// https://github.com/vuejs/vue-class-component/issues/263
data(): Record<string, unknown> {
return {
fetchAsyncData: debounce(this.asyncData, 200),
};
}
@Watch("config")
watchConfig(config: IConfig): void {
if (!config.geocoding.autocomplete) {
// If autocomplete is disabled, we put a larger debounce value
// so that we don't request with incomplete address
this.fetchAsyncData = debounce(this.asyncData, 2000);
}
}
async asyncData(query: string): Promise<void> {
if (!query.length) {
this.addressData = [];
this.selected = new Address();
return;
}
if (query.length < 3) {
this.addressData = [];
return;
}
this.isFetching = true;
const result = await this.$apollo.query({
query: ADDRESS,
fetchPolicy: "network-only",
variables: {
query,
locale: this.$i18n.locale,
},
});
this.addressData = result.data.searchAddress.map(
(address: IAddress) => new Address(address)
);
this.isFetching = false;
}
async locateMe(): Promise<void> {
this.gettingLocation = true;
this.gettingLocationError = null;
try {
this.location = await this.getLocation();
this.mapDefaultZoom = 12;
this.reverseGeoCode(
new LatLng(
this.location.coords.latitude,
this.location.coords.longitude
),
12
);
} catch (e: any) {
this.gettingLocationError = e.message;
}
this.gettingLocation = false;
}
async reverseGeoCode(e: LatLng, zoom: number): Promise<void> {
// If the position has been updated through autocomplete selection, no need to geocode it!
if (this.checkCurrentPosition(e)) return;
const result = await this.$apollo.query({
query: REVERSE_GEOCODE,
variables: {
latitude: e.lat,
longitude: e.lng,
zoom,
locale: this.$i18n.locale,
},
});
this.addressData = result.data.reverseGeocode.map(
(address: IAddress) => new Address(address)
);
if (this.addressData.length > 0) {
const defaultAddress = new Address(this.addressData[0]);
this.selected = defaultAddress;
this.$emit("input", this.selected);
this.queryText = `${defaultAddress.poiInfos.name} ${defaultAddress.poiInfos.alternativeName}`;
}
}
checkCurrentPosition(e: LatLng): boolean {
if (!this.selected || !this.selected.geom) return false;
const lat = parseFloat(this.selected.geom.split(";")[1]);
const lon = parseFloat(this.selected.geom.split(";")[0]);
return e.lat === lat && e.lng === lon;
}
// eslint-disable-next-line no-undef
async getLocation(): Promise<GeolocationPosition> {
let errorMessage = this.$t("Failed to get location.");
return new Promise((resolve, reject) => {
if (!("geolocation" in navigator)) {
reject(new Error(errorMessage as string));
}
navigator.geolocation.getCurrentPosition(
(pos) => {
resolve(pos);
},
(err) => {
switch (err.code) {
// eslint-disable-next-line no-undef
case GeolocationPositionError.PERMISSION_DENIED:
errorMessage = this.$t("The geolocation prompt was denied.");
break;
// eslint-disable-next-line no-undef
case GeolocationPositionError.POSITION_UNAVAILABLE:
errorMessage = this.$t("Your position was not available.");
break;
// eslint-disable-next-line no-undef
case GeolocationPositionError.TIMEOUT:
errorMessage = this.$t("Geolocation was not determined in time.");
break;
default:
errorMessage = err.message;
}
reject(new Error(errorMessage as string));
}
);
});
}
get fieldErrors(): Array<Record<string, boolean>> {
const errors = [];
if (this.gettingLocationError) {
errors.push({
[this.gettingLocationError]: true,
});
}
return errors;
}
// eslint-disable-next-line class-methods-use-this
get isSecureContext(): boolean {
return window.isSecureContext;
}
}