feat(addresses): Allow to enter manual addresses

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2023-04-07 17:54:06 +02:00
parent 50ab531156
commit 85d643d0ec
No known key found for this signature in database
GPG key ID: A061B9DDE0CA0773
6 changed files with 304 additions and 166 deletions

View file

@ -10,32 +10,24 @@
> >
<template #label> <template #label>
{{ actualLabel }} {{ actualLabel }}
<span v-if="gettingLocation">{{ t("Getting location") }}</span>
</template> </template>
<p class="control" v-if="canShowLocateMeButton"> <o-button
<o-loading v-if="canShowLocateMeButton"
:full-page="false" ref="mapMarker"
v-model:active="gettingLocation" icon-right="map-marker"
:can-cancel="false" @click="locateMe"
:container="mapMarker?.$el" :title="t('Use my location')"
/> />
<o-button
ref="mapMarker"
icon-right="map-marker"
@click="locateMe"
:title="t('Use my location')"
/>
</p>
<o-autocomplete <o-autocomplete
:data="addressData" :data="addressData"
v-model="queryText" v-model="queryTextWithDefault"
:placeholder="placeholderWithDefault" :placeholder="placeholderWithDefault"
:customFormatter="(elem: IAddress) => addressFullName(elem)" :customFormatter="(elem: IAddress) => addressFullName(elem)"
:debounceTyping="debounceDelay" :debounceTyping="debounceDelay"
@typing="asyncData" @typing="asyncData"
:icon="canShowLocateMeButton ? null : 'map-marker'" :icon="canShowLocateMeButton ? null : 'map-marker'"
expanded expanded
@select="updateSelected" @select="setSelected"
:id="id" :id="id"
:disabled="disabled" :disabled="disabled"
dir="auto" dir="auto"
@ -49,35 +41,39 @@
<small>{{ addressToPoiInfos(option).alternativeName }}</small> <small>{{ addressToPoiInfos(option).alternativeName }}</small>
</template> </template>
<template #empty> <template #empty>
<span v-if="isFetching">{{ t("Searching") }}</span> <template v-if="isFetching">{{ t("Searching") }}</template>
<div v-else-if="queryText.length >= 3" class="enabled"> <template v-else-if="queryTextWithDefault.length >= 3">
<span>{{ <p>
t('No results for "{queryText}"', { queryText }) {{
}}</span> t('No results for "{queryText}"', {
<span>{{ queryText: queryTextWithDefault,
t( })
"You can try another search term or drag and drop the marker on the map", }}
{ </p>
queryText, <p>
} {{
) t(
}}</span> "You can try another search term or add the address details manually below."
<!-- <p class="control" @click="openNewAddressModal">--> )
<!-- <button type="button" class="button is-primary">{{ t('Add') }}</button>--> }}
<!-- </p>--> </p>
</div> </template>
</template> </template>
</o-autocomplete> </o-autocomplete>
<o-button <o-button
:disabled="!queryText" :disabled="!queryTextWithDefault"
@click="resetAddress" @click="resetAddress"
class="reset-area" class="reset-area"
icon-left="close" icon-left="close"
:title="t('Clear address field')" :title="t('Clear address field')"
/> />
</o-field> </o-field>
<p v-if="gettingLocation" class="flex gap-2">
<Loading class="animate-spin" />
{{ t("Getting location") }}
</p>
<div <div
class="mt-2 p-2 rounded-lg shadow-md dark:bg-violet-3" class="mt-2 p-2 rounded-lg shadow-md bg-white dark:bg-violet-3"
v-if="!hideSelected && (selected?.originId || selected?.url)" v-if="!hideSelected && (selected?.originId || selected?.url)"
> >
<div class=""> <div class="">
@ -90,16 +86,80 @@
</div> </div>
</div> </div>
</div> </div>
<div class="map" v-if="!hideMap && selected && selected.geom"> <o-collapse
v-model:open="detailsAddress"
:aria-id="`${id}-address-details`"
class="my-3"
>
<template #trigger>
<o-button
variant="primary"
outlined
:aria-controls="`${id}-address-details`"
:icon-right="detailsAddress ? 'chevron-up' : 'chevron-down'"
>
{{ t("Details") }}
</o-button>
</template>
<form @submit.prevent="saveManualAddress">
<header>
<h2>{{ t("Manually enter address") }}</h2>
</header>
<section>
<o-field :label="t('Name')" labelFor="addressNameInput">
<o-input
aria-required="true"
required
v-model="selected.description"
id="addressNameInput"
/>
</o-field>
<o-field :label="t('Street')" labelFor="streetInput">
<o-input v-model="selected.street" id="streetInput" />
</o-field>
<o-field grouped>
<o-field :label="t('Postal Code')" labelFor="postalCodeInput">
<o-input v-model="selected.postalCode" id="postalCodeInput" />
</o-field>
<o-field :label="t('Locality')" labelFor="localityInput">
<o-input v-model="selected.locality" id="localityInput" />
</o-field>
</o-field>
<o-field grouped>
<o-field :label="t('Region')" labelFor="regionInput">
<o-input v-model="selected.region" id="regionInput" />
</o-field>
<o-field :label="t('Country')" labelFor="countryInput">
<o-input v-model="selected.country" id="countryInput" />
</o-field>
</o-field>
</section>
<footer class="mt-3 flex gap-2 items-center">
<o-button native-type="submit">
{{ t("Save") }}
</o-button>
<o-button outlined type="button" @click="resetAddress">
{{ t("Clear") }}
</o-button>
<p>
{{
t(
"You can drag and drop the marker below to the desired location"
)
}}
</p>
</footer>
</form>
</o-collapse>
<div class="map" v-if="!hideMap">
<map-leaflet <map-leaflet
:coords="selected.geom" :coords="selected.geom ?? defaultCoords"
:marker="{ :marker="mapMarkerValue"
text: [
addressToPoiInfos(selected).name,
addressToPoiInfos(selected).alternativeName,
],
icon: addressToPoiInfos(selected).poiIcon.icon,
}"
:updateDraggableMarkerCallback="reverseGeoCode" :updateDraggableMarkerCallback="reverseGeoCode"
:options="{ zoom: mapDefaultZoom }" :options="{ zoom: mapDefaultZoom }"
:readOnly="false" :readOnly="false"
@ -114,15 +174,25 @@ import {
IAddress, IAddress,
addressFullName, addressFullName,
addressToPoiInfos, addressToPoiInfos,
resetAddress as resetAddressAction,
} from "../../types/address.model"; } from "../../types/address.model";
import AddressInfo from "../../components/Address/AddressInfo.vue"; import AddressInfo from "../../components/Address/AddressInfo.vue";
import { computed, ref, watch, defineAsyncComponent } from "vue"; import {
computed,
ref,
watch,
defineAsyncComponent,
onMounted,
reactive,
onBeforeMount,
} from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useGeocodingAutocomplete } from "@/composition/apollo/config"; import { useGeocodingAutocomplete } from "@/composition/apollo/config";
import { ADDRESS } from "@/graphql/address"; import { ADDRESS } from "@/graphql/address";
import { useReverseGeocode } from "@/composition/apollo/address"; import { useReverseGeocode } from "@/composition/apollo/address";
import { useLazyQuery } from "@vue/apollo-composable"; import { useLazyQuery } from "@vue/apollo-composable";
import { AddressSearchType } from "@/types/enums"; import { AddressSearchType } from "@/types/enums";
import Loading from "vue-material-design-icons/Loading.vue";
const MapLeaflet = defineAsyncComponent( const MapLeaflet = defineAsyncComponent(
() => import("@/components/LeafletMap.vue") () => import("@/components/LeafletMap.vue")
); );
@ -139,29 +209,38 @@ const props = withDefaults(
hideSelected?: boolean; hideSelected?: boolean;
placeholder?: string; placeholder?: string;
resultType?: AddressSearchType; resultType?: AddressSearchType;
defaultCoords?: string;
}>(), }>(),
{ {
defaultCoords: "0;0",
labelClass: "", labelClass: "",
defaultText: "",
disabled: false, disabled: false,
hideMap: false, hideMap: false,
hideSelected: false, hideSelected: false,
} }
); );
// const addressModalActive = ref(false); const componentId = ref(0);
const componentId = 0;
const emit = defineEmits(["update:modelValue"]); const emit = defineEmits(["update:modelValue"]);
const gettingLocationError = ref<string | null>(null); const gettingLocationError = ref<string | null>(null);
const gettingLocation = ref(false); const gettingLocation = ref(false);
const mapDefaultZoom = ref(15); const mapDefaultZoom = computed(() => {
if (selected.description) {
return 15;
}
return 5;
});
const addressData = ref<IAddress[]>([]); const addressData = ref<IAddress[]>([]);
const selected = ref<IAddress | null>(null); const defaultAddress = new Address();
defaultAddress.geom = undefined;
defaultAddress.id = undefined;
const selected = reactive<IAddress>(defaultAddress);
const detailsAddress = ref(false);
const isFetching = ref(false); const isFetching = ref(false);
@ -171,40 +250,45 @@ const placeholderWithDefault = computed(
() => props.placeholder ?? t("e.g. 10 Rue Jangot") () => props.placeholder ?? t("e.g. 10 Rue Jangot")
); );
// created(): void { onBeforeMount(() => {
// componentId += 1; componentId.value += 1;
// } });
const id = computed((): string => { const id = computed((): string => {
return `full-address-autocomplete-${componentId}`; return `full-address-autocomplete-${componentId.value}`;
}); });
const modelValue = computed(() => props.modelValue); const modelValue = computed(() => props.modelValue);
watch(modelValue, () => { watch(modelValue, () => {
if (!modelValue.value) return; console.debug("modelValue changed");
selected.value = modelValue.value; setSelected(modelValue.value);
}); });
const updateSelected = (option: IAddress): void => { onMounted(() => {
if (option == null) return; setSelected(modelValue.value);
selected.value = option; });
emit("update:modelValue", selected.value);
const setSelected = (newValue: IAddress | null) => {
if (!newValue) return;
console.debug("setting selected to model value");
Object.assign(selected, newValue);
}; };
// const resetPopup = (): void => { const saveManualAddress = (): void => {
// selected.value = new Address(); console.debug("saving address");
// }; selected.id = undefined;
selected.originId = undefined;
// const openNewAddressModal = (): void => { selected.url = undefined;
// resetPopup(); emit("update:modelValue", selected);
// addressModalActive.value = true; detailsAddress.value = false;
// }; };
const checkCurrentPosition = (e: LatLng): boolean => { const checkCurrentPosition = (e: LatLng): boolean => {
if (!selected.value?.geom) return false; console.debug("checkCurrentPosition");
const lat = parseFloat(selected.value?.geom.split(";")[1]); if (!selected?.geom || !e) return false;
const lon = parseFloat(selected.value?.geom.split(";")[0]); const lat = parseFloat(selected?.geom.split(";")[1]);
const lon = parseFloat(selected?.geom.split(";")[0]);
return e.lat === lat && e.lng === lon; return e.lat === lat && e.lng === lon;
}; };
@ -238,12 +322,11 @@ onAddressSearchResult((result) => {
isFetching.value = false; isFetching.value = false;
}); });
const searchQuery = ref("");
const asyncData = async (query: string): Promise<void> => { const asyncData = async (query: string): Promise<void> => {
console.debug("Finding addresses");
if (!query.length) { if (!query.length) {
addressData.value = []; addressData.value = [];
selected.value = new Address(); Object.assign(selected, defaultAddress);
return; return;
} }
@ -254,33 +337,39 @@ const asyncData = async (query: string): Promise<void> => {
isFetching.value = true; isFetching.value = true;
searchQuery.value = query;
searchAddress(undefined, { searchAddress(undefined, {
query: searchQuery.value, query,
locale: locale, locale: locale,
type: props.resultType, type: props.resultType,
}); });
}; };
const queryText = computed({ const selectedAddressText = computed(() => {
if (!selected) return undefined;
return addressFullName(selected);
});
const queryText = ref();
const queryTextWithDefault = computed({
get() { get() {
console.log("queryTextWithDefault 1", queryText.value);
console.log("queryTextWithDefault 2", selectedAddressText.value);
console.log("queryTextWithDefault 3", props.defaultText);
return ( return (
(selected.value ? addressFullName(selected.value) : props.defaultText) ?? queryText.value ?? selectedAddressText.value ?? props.defaultText ?? ""
""
); );
}, },
set(text) { set(newValue: string) {
if (text === "" && selected.value?.id) { queryText.value = newValue;
console.debug("doing reset");
resetAddress();
}
}, },
}); });
const resetAddress = (): void => { const resetAddress = (): void => {
console.debug("resetting address");
emit("update:modelValue", null); emit("update:modelValue", null);
selected.value = new Address(); resetAddressAction(selected);
queryTextWithDefault.value = "";
}; };
const locateMe = async (): Promise<void> => { const locateMe = async (): Promise<void> => {
@ -288,7 +377,7 @@ const locateMe = async (): Promise<void> => {
gettingLocationError.value = null; gettingLocationError.value = null;
try { try {
const location = await getLocation(); const location = await getLocation();
mapDefaultZoom.value = 12; // mapDefaultZoom.value = 12;
reverseGeoCode( reverseGeoCode(
new LatLng(location.coords.latitude, location.coords.longitude), new LatLng(location.coords.latitude, location.coords.longitude),
12 12
@ -308,15 +397,26 @@ onReverseGeocodeResult((result) => {
addressData.value = data.reverseGeocode; addressData.value = data.reverseGeocode;
if (addressData.value.length > 0) { if (addressData.value.length > 0) {
const defaultAddress = addressData.value[0]; const foundAddress = addressData.value[0];
selected.value = defaultAddress; Object.assign(selected, foundAddress);
emit("update:modelValue", selected.value); console.debug("reverse geocode succeded, setting new address");
queryTextWithDefault.value = addressFullName(foundAddress);
emit("update:modelValue", selected);
} }
}); });
const reverseGeoCode = (e: LatLng, zoom: number) => { const reverseGeoCode = (e: LatLng, zoom: number) => {
console.debug("reverse geocode");
// If the details is opened, just update coords, don't reverse geocode
if (e && detailsAddress.value) {
selected.geom = `${e.lng};${e.lat}`;
console.debug("no reverse geocode, just setting new coords");
return;
}
// If the position has been updated through autocomplete selection, no need to geocode it! // If the position has been updated through autocomplete selection, no need to geocode it!
if (checkCurrentPosition(e)) return; if (!e || checkCurrentPosition(e)) return;
loadReverseGeocode(undefined, { loadReverseGeocode(undefined, {
latitude: e.lat, latitude: e.lat,
@ -358,6 +458,17 @@ const getLocation = async (): Promise<GeolocationPosition> => {
}); });
}; };
const mapMarkerValue = computed(() => {
if (!selected.description) return undefined;
return {
text: [
addressToPoiInfos(selected).name,
addressToPoiInfos(selected).alternativeName,
],
icon: addressToPoiInfos(selected).poiIcon.icon,
};
});
const fieldErrors = computed(() => { const fieldErrors = computed(() => {
return gettingLocationError.value; return gettingLocationError.value;
}); });

View file

@ -21,10 +21,15 @@
<l-marker <l-marker
:lat-lng="[lat, lon]" :lat-lng="[lat, lon]"
@add="openPopup" @add="openPopup"
@update:latLng="updateDraggableMarkerPosition" @update:latLng="updateDraggableMarkerPositionDebounced"
:draggable="!readOnly" :draggable="!readOnly"
> >
<l-icon> <l-icon
:icon-size="[48, 48]"
:shadow-size="[30, 30]"
:icon-anchor="[24, 48]"
:popup-anchor="[-24, -40]"
>
<MapMarker :size="48" class="text-mbz-purple" /> <MapMarker :size="48" class="text-mbz-purple" />
</l-icon> </l-icon>
<l-popup v-if="popupMultiLine" :options="{ offset: new Point(22, 8) }"> <l-popup v-if="popupMultiLine" :options="{ offset: new Point(22, 8) }">
@ -63,6 +68,7 @@ import { useI18n } from "vue-i18n";
import Locatecontrol from "leaflet.locatecontrol"; import Locatecontrol from "leaflet.locatecontrol";
import CrosshairsGps from "vue-material-design-icons/CrosshairsGps.vue"; import CrosshairsGps from "vue-material-design-icons/CrosshairsGps.vue";
import MapMarker from "vue-material-design-icons/MapMarker.vue"; import MapMarker from "vue-material-design-icons/MapMarker.vue";
import { useDebounceFn } from "@vueuse/core";
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -159,22 +165,25 @@ const mergedOptions = computed((): Record<string, unknown> => {
}); });
const lat = computed((): number => { const lat = computed((): number => {
return Number.parseFloat(props.coords?.split(";")[1]); return Number.parseFloat(props.coords?.split(";")[1] || "0");
}); });
const lon = computed((): number => { const lon = computed((): number => {
return Number.parseFloat(props.coords.split(";")[0]); return Number.parseFloat(props.coords?.split(";")[0] || "0");
}); });
const popupMultiLine = computed((): Array<string | undefined> => { const popupMultiLine = computed((): Array<string> | undefined => {
if (Array.isArray(props.marker?.text)) { if (Array.isArray(props.marker?.text)) {
return props.marker?.text as string[]; return props.marker?.text as string[];
} }
return [props.marker?.text]; if (props.marker?.text) {
return [props.marker?.text];
}
return undefined;
}); });
const clickMap = (event: LeafletMouseEvent): void => { const clickMap = (event: LeafletMouseEvent): void => {
updateDraggableMarkerPosition(event.latlng); updateDraggableMarkerPositionDebounced(event.latlng);
}; };
const updateDraggableMarkerPosition = (e: LatLng): void => { const updateDraggableMarkerPosition = (e: LatLng): void => {
@ -183,6 +192,10 @@ const updateDraggableMarkerPosition = (e: LatLng): void => {
} }
}; };
const updateDraggableMarkerPositionDebounced = useDebounceFn((e: LatLng) => {
updateDraggableMarkerPosition(e);
}, 1000);
const updateZoom = (newZoom: number): void => { const updateZoom = (newZoom: number): void => {
zoom.value = newZoom; zoom.value = newZoom;
}; };
@ -211,4 +224,9 @@ div.map-container {
</style> </style>
<style> <style>
@import "leaflet.locatecontrol/dist/L.Control.Locate.css"; @import "leaflet.locatecontrol/dist/L.Control.Locate.css";
.leaflet-div-icon {
background: unset;
border: unset;
}
</style> </style>

View file

@ -54,6 +54,50 @@ export const LIST_GROUPS = gql`
${ACTOR_FRAGMENT} ${ACTOR_FRAGMENT}
`; `;
export const GROUP_VERY_BASIC_FIELDS_FRAGMENTS = gql`
fragment GroupVeryBasicFields on Group {
...ActorFragment
suspended
visibility
openness
manuallyApprovesFollowers
physicalAddress {
description
street
locality
postalCode
region
country
geom
type
id
originId
url
}
avatar {
id
url
name
metadata {
width
height
blurhash
}
}
banner {
id
url
name
metadata {
width
height
blurhash
}
}
}
${ACTOR_FRAGMENT}
`;
export const GROUP_BASIC_FIELDS_FRAGMENTS = gql` export const GROUP_BASIC_FIELDS_FRAGMENTS = gql`
fragment GroupBasicFields on Group { fragment GroupBasicFields on Group {
...ActorFragment ...ActorFragment
@ -296,17 +340,10 @@ export const UPDATE_GROUP = gql`
physicalAddress: $physicalAddress physicalAddress: $physicalAddress
manuallyApprovesFollowers: $manuallyApprovesFollowers manuallyApprovesFollowers: $manuallyApprovesFollowers
) { ) {
...ActorFragment ...GroupVeryBasicFields
visibility
openness
manuallyApprovesFollowers
banner {
id
url
}
} }
} }
${ACTOR_FRAGMENT} ${GROUP_VERY_BASIC_FIELDS_FRAGMENTS}
`; `;
export const DELETE_GROUP = gql` export const DELETE_GROUP = gql`

View file

@ -93,7 +93,10 @@ export function addressToPoiInfos(address: IAddress): IPoiInfo {
switch (addressType) { switch (addressType) {
case "house": case "house":
name = address.description; name = address.description;
alternativeName = [address.postalCode, address.locality, address.country] alternativeName = (
address.description !== address.street ? [address.street] : []
)
.concat([address.postalCode, address.locality, address.country])
.filter((zone) => zone) .filter((zone) => zone)
.join(", "); .join(", ");
poiIcon = poiIcons.defaultAddress; poiIcon = poiIcons.defaultAddress;
@ -123,8 +126,11 @@ export function addressToPoiInfos(address: IAddress): IPoiInfo {
alternativeName = ""; alternativeName = "";
if (address.street && address.street.trim()) { if (address.street && address.street.trim()) {
alternativeName = `${address.street}`; alternativeName = `${address.street}`;
if (address.postalCode) {
alternativeName += `, ${address.postalCode}`;
}
if (address.locality) { if (address.locality) {
alternativeName += ` (${address.locality})`; alternativeName += `, ${address.locality}`;
} }
} else if (address.locality && address.locality.trim()) { } else if (address.locality && address.locality.trim()) {
alternativeName = `${address.locality}, ${address.region}, ${address.country}`; alternativeName = `${address.locality}, ${address.region}, ${address.country}`;
@ -158,3 +164,19 @@ export function addressFullName(address: IAddress): string {
} }
return ""; return "";
} }
export function resetAddress(address: IAddress): void {
address.id = undefined;
address.description = "";
address.street = "";
address.locality = "";
address.postalCode = "";
address.region = "";
address.country = "";
address.type = "";
address.geom = undefined;
address.url = undefined;
address.originId = undefined;
address.timezone = undefined;
address.pictureInfo = undefined;
}

View file

@ -612,59 +612,6 @@ const FullAddressAutoComplete = defineAsyncComponent(
() => import("@/components/Event/FullAddressAutoComplete.vue") () => import("@/components/Event/FullAddressAutoComplete.vue")
); );
// apollo: {
// config: CONFIG_EDIT_EVENT,
// event: {
// query: FETCH_EVENT,
// variables() {
// return {
// uuid: this.eventId,
// };
// },
// update(data) {
// let event = data.event;
// if (this.isDuplicate) {
// event = { ...event, organizerActor: this.currentActor };
// }
// return new EventModel(event);
// },
// skip() {
// return !this.eventId;
// },
// },
// person: {
// query: PERSON_STATUS_GROUP,
// fetchPolicy: "cache-and-network",
// variables() {
// return {
// id: this.currentActor.id,
// group: usernameWithDomain(this.event?.attributedTo),
// };
// },
// skip() {
// return (
// !this.event?.attributedTo ||
// !this.event?.attributedTo?.preferredUsername
// );
// },
// },
// group: {
// query: FETCH_GROUP_PUBLIC,
// fetchPolicy: "cache-and-network",
// variables() {
// return {
// name: this.event?.attributedTo?.preferredUsername,
// };
// },
// skip() {
// return (
// !this.event?.attributedTo ||
// !this.event?.attributedTo?.preferredUsername
// );
// },
// },
// },
const { t } = useI18n({ useScope: "global" }); const { t } = useI18n({ useScope: "global" });
useHead({ useHead({
@ -689,7 +636,6 @@ const unmodifiedEvent = ref<IEditableEvent>(new EventModel());
const pictureFile = ref<File | null>(null); const pictureFile = ref<File | null>(null);
// const canPromote = ref(true);
const limitedPlaces = ref(false); const limitedPlaces = ref(false);
const showFixedNavbar = ref(true); const showFixedNavbar = ref(true);
@ -1051,6 +997,7 @@ const buildVariables = async () => {
res.picture = { mediaId: event.value?.picture.id }; res.picture = { mediaId: event.value?.picture.id };
} }
} }
console.debug("builded variables", res);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }

View file

@ -369,7 +369,10 @@ const currentAddress = computed({
}, },
set(address: IAddress) { set(address: IAddress) {
if (editableGroup.value) { if (editableGroup.value) {
editableGroup.value.physicalAddress = address; editableGroup.value = {
...editableGroup.value,
physicalAddress: address,
};
} }
}, },
}); });