From 13f33b8393d57c4978b64c45b0c271bf7da17e0f Mon Sep 17 00:00:00 2001
From: setop <setop@zoocoop.com>
Date: Fri, 5 Mar 2021 16:19:42 +0000
Subject: [PATCH] make search with location bookmarkable (fix #482) using
 geohash set a default radius when a location is set, so it does not trigger a
 worldwide search reduce pressure on server and view with debonce

---
 .../components/Event/AddressAutoComplete.vue  |  15 ++-
 js/src/views/Admin/Settings.vue               |   2 +-
 js/src/views/Search.vue                       | 119 +++++++++++++++---
 3 files changed, 115 insertions(+), 21 deletions(-)

diff --git a/js/src/components/Event/AddressAutoComplete.vue b/js/src/components/Event/AddressAutoComplete.vue
index 407c991f7..9be4b29aa 100644
--- a/js/src/components/Event/AddressAutoComplete.vue
+++ b/js/src/components/Event/AddressAutoComplete.vue
@@ -81,7 +81,7 @@ export default class AddressAutoComplete extends Vue {
 
   isFetching = false;
 
-  queryText: string = (this.value && new Address(this.value).fullName) || "";
+  initialQueryText = "";
 
   addressModalActive = false;
 
@@ -149,12 +149,21 @@ export default class AddressAutoComplete extends Vue {
     }
   }
 
+  get queryText(): string {
+    if (this.value) {
+      return new Address(this.value).fullName;
+    }
+    return this.initialQueryText;
+  }
+
+  set queryText(queryText: string) {
+    this.initialQueryText = queryText;
+  }
+
   @Watch("value")
   updateEditing(): void {
     if (!this.value?.id) return;
     this.selected = this.value;
-    const address = new Address(this.selected);
-    this.queryText = `${address.poiInfos.name} ${address.poiInfos.alternativeName}`;
   }
 
   updateSelected(option: IAddress): void {
diff --git a/js/src/views/Admin/Settings.vue b/js/src/views/Admin/Settings.vue
index 2c4a05444..10214a49c 100644
--- a/js/src/views/Admin/Settings.vue
+++ b/js/src/views/Admin/Settings.vue
@@ -332,7 +332,7 @@
   </div>
 </template>
 <script lang="ts">
-import { Component, Vue, Watch } from "vue-property-decorator";
+import { Component, Vue } from "vue-property-decorator";
 import {
   ADMIN_SETTINGS,
   SAVE_ADMIN_SETTINGS,
diff --git a/js/src/views/Search.vue b/js/src/views/Search.vue
index 6a3c2b044..f5132e4df 100644
--- a/js/src/views/Search.vue
+++ b/js/src/views/Search.vue
@@ -27,7 +27,9 @@
               <address-auto-complete
                 v-model="location"
                 id="location"
+                ref="aac"
                 :placeholder="$t('For instance: London')"
+                @input="locchange"
               />
             </b-field>
             <b-field :label="$t('Radius')" label-for="radius">
@@ -63,7 +65,7 @@
     </section>
     <section
       class="events-featured"
-      v-if="!tag && !(search || location.geom || when !== 'any')"
+      v-if="!canSearchEvents && !canSearchGroups"
     >
       <b-loading :active.sync="$apollo.loading"></b-loading>
       <h2 class="title">{{ $t("Featured events") }}</h2>
@@ -173,7 +175,7 @@
 
 <script lang="ts">
 import { Component, Prop, Vue } from "vue-property-decorator";
-import ngeohash from "ngeohash";
+import ngeohash, { GeographicPoint } from "ngeohash";
 import {
   endOfToday,
   addDays,
@@ -188,7 +190,6 @@ import {
   eachWeekendOfInterval,
 } from "date-fns";
 import { SearchTabs } from "@/types/enums";
-import { RawLocation } from "vue-router";
 import EventCard from "../components/Event/EventCard.vue";
 import { FETCH_EVENTS } from "../graphql/event";
 import { IEvent } from "../types/event.model";
@@ -200,6 +201,7 @@ import { Paginate } from "../types/paginate";
 import { IGroup } from "../types/actor";
 import GroupCard from "../components/Group/GroupCard.vue";
 import { CONFIG } from "../graphql/config";
+import { REVERSE_GEOCODE } from "../graphql/address";
 
 interface ISearchTimeOption {
   label: string;
@@ -211,6 +213,14 @@ const EVENT_PAGE_LIMIT = 10;
 
 const GROUP_PAGE_LIMIT = 10;
 
+const DEFAULT_RADIUS = 25; // value to set if radius is null but location set
+
+const DEFAULT_ZOOM = 11; // zoom on a city
+
+const GEOHASH_DEPTH = 9; // put enough accuracy, radius will be used anyway
+
+const THROTTLE = 2000; // minimum interval in ms between two requests
+
 @Component({
   components: {
     EventCard,
@@ -235,9 +245,9 @@ const GROUP_PAGE_LIMIT = 10;
           limit: EVENT_PAGE_LIMIT,
         };
       },
-      debounce: 300,
+      throttle: THROTTLE,
       skip() {
-        return !this.tag && !this.geohash && this.end === null;
+        return !this.canSearchEvents;
       },
     },
     searchGroups: {
@@ -252,8 +262,9 @@ const GROUP_PAGE_LIMIT = 10;
           limit: GROUP_PAGE_LIMIT,
         };
       },
+      throttle: THROTTLE,
       skip() {
-        return !this.search && !this.geohash;
+        return !this.canSearchGroups;
       },
     },
   },
@@ -334,6 +345,14 @@ export default class Search extends Vue {
 
   GROUP_PAGE_LIMIT = GROUP_PAGE_LIMIT;
 
+  $refs!: {
+    aac: AddressAutoComplete;
+  };
+
+  mounted(): void {
+    this.prepareLocation(this.$route.query.geohash as string);
+  }
+
   radiusString = (radius: number | null): string => {
     if (radius) {
       return this.$tc("{nb} km", radius, { nb: radius }) as string;
@@ -352,13 +371,10 @@ export default class Search extends Vue {
   }
 
   set search(term: string | undefined) {
-    const route: RawLocation = {
+    this.$router.replace({
       name: RouteName.SEARCH,
-    };
-    if (term !== "") {
-      route.query = { ...this.$route.query, term };
-    }
-    this.$router.replace(route);
+      query: { ...this.$route.query, term },
+    });
   }
 
   get activeTab(): SearchTabs {
@@ -374,6 +390,21 @@ export default class Search extends Vue {
     });
   }
 
+  get geohash(): string | undefined {
+    if (this.location?.geom) {
+      const [lon, lat] = this.location.geom.split(";");
+      return ngeohash.encode(lat, lon, GEOHASH_DEPTH);
+    }
+    return undefined;
+  }
+
+  set geohash(value: string | undefined) {
+    this.$router.replace({
+      name: RouteName.SEARCH,
+      query: { ...this.$route.query, geohash: value },
+    });
+  }
+
   get radius(): number | null {
     if (this.$route.query.radius === "any") {
       return null;
@@ -411,14 +442,43 @@ export default class Search extends Vue {
     return { start: startOfDay(start), end: endOfDay(end) };
   }
 
-  get geohash(): string | undefined {
-    if (this.location?.geom) {
-      const [lon, lat] = this.location.geom.split(";");
-      return ngeohash.encode(lat, lon, 6);
+  private prepareLocation(value: string | undefined): void {
+    if (value !== undefined) {
+      // decode
+      const latlon = ngeohash.decode(value);
+      // set location
+      this.reverseGeoCode(latlon, DEFAULT_ZOOM);
     }
-    return undefined;
   }
 
+  async reverseGeoCode(e: GeographicPoint, zoom: number): Promise<void> {
+    const result = await this.$apollo.query({
+      query: REVERSE_GEOCODE,
+      variables: {
+        latitude: e.latitude,
+        longitude: e.longitude,
+        zoom,
+        locale: this.$i18n.locale,
+      },
+    });
+    const addressData = result.data.reverseGeocode.map(
+      (address: IAddress) => new Address(address)
+    );
+    if (addressData.length > 0) {
+      this.location = addressData[0];
+    }
+  }
+
+  locchange = (e: IAddress): void => {
+    if (this.radius === undefined || this.radius === null) {
+      this.radius = DEFAULT_RADIUS;
+    }
+    if (e.geom) {
+      const [lon, lat] = e.geom.split(";");
+      this.geohash = ngeohash.encode(lat, lon, GEOHASH_DEPTH);
+    }
+  };
+
   get start(): Date | undefined {
     if (this.options[this.when]) {
       return this.options[this.when].start;
@@ -432,6 +492,31 @@ export default class Search extends Vue {
     }
     return undefined;
   }
+
+  get canSearchGroups(): boolean {
+    return (
+      this.stringExists(this.search) ||
+      (this.stringExists(this.geohash) && this.valueExists(this.radius))
+    );
+  }
+
+  get canSearchEvents(): boolean {
+    return (
+      this.stringExists(this.search) ||
+      this.stringExists(this.tag) ||
+      (this.stringExists(this.geohash) && this.valueExists(this.radius)) ||
+      this.valueExists(this.end)
+    );
+  }
+
+  // helper functions for skip
+  private valueExists(value: any): boolean {
+    return value !== undefined && value !== null;
+  }
+
+  private stringExists(value: string | undefined): boolean {
+    return this.valueExists(value) && (value as string).length > 0;
+  }
 }
 </script>