From 69e91e89f58bdff1ee605c43f50f590fbe97457f Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Sat, 6 Nov 2021 14:37:45 +0100
Subject: [PATCH] Allow to search events by online status

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 js/src/graphql/event.ts        |   2 -
 js/src/graphql/search.ts       |  14 +-
 js/src/i18n/en_US.json         |   4 +-
 js/src/i18n/fr_FR.json         |   4 +-
 js/src/types/event.model.ts    |   2 +
 js/src/views/Search.vue        | 258 ++++++++++++++++++++++++---------
 lib/graphql/schema/search.ex   |  10 ++
 lib/mobilizon/events/events.ex |  13 ++
 8 files changed, 232 insertions(+), 75 deletions(-)

diff --git a/js/src/graphql/event.ts b/js/src/graphql/event.ts
index 63cfd8967..c45fd44d6 100644
--- a/js/src/graphql/event.ts
+++ b/js/src/graphql/event.ts
@@ -158,8 +158,6 @@ export const FETCH_EVENTS = gql`
           url
         }
         publishAt
-        # online_address,
-        # phone_address,
         physicalAddress {
           ...AdressFragment
         }
diff --git a/js/src/graphql/search.ts b/js/src/graphql/search.ts
index a79d318a2..c0124840a 100644
--- a/js/src/graphql/search.ts
+++ b/js/src/graphql/search.ts
@@ -1,6 +1,7 @@
 import gql from "graphql-tag";
 import { ACTOR_FRAGMENT } from "./actor";
 import { ADDRESS_FRAGMENT } from "./address";
+import { EVENT_OPTIONS_FRAGMENT } from "./event_options";
 import { TAG_FRAGMENT } from "./tags";
 
 export const SEARCH_EVENTS_AND_GROUPS = gql`
@@ -9,9 +10,11 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
     $radius: Float
     $tags: String
     $term: String
+    $type: EventType
     $beginsOn: DateTime
     $endsOn: DateTime
-    $page: Int
+    $eventPage: Int
+    $groupPage: Int
     $limit: Int
   ) {
     searchEvents(
@@ -19,9 +22,10 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
       radius: $radius
       tags: $tags
       term: $term
+      type: $type
       beginsOn: $beginsOn
       endsOn: $endsOn
-      page: $page
+      page: $eventPage
       limit: $limit
     ) {
       total
@@ -46,6 +50,9 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
         attributedTo {
           ...ActorFragment
         }
+        options {
+          ...EventOptions
+        }
         __typename
       }
     }
@@ -53,7 +60,7 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
       term: $term
       location: $location
       radius: $radius
-      page: $page
+      page: $groupPage
       limit: $limit
     ) {
       total
@@ -75,6 +82,7 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
       }
     }
   }
+  ${EVENT_OPTIONS_FRAGMENT}
   ${TAG_FRAGMENT}
   ${ADDRESS_FRAGMENT}
   ${ACTOR_FRAGMENT}
diff --git a/js/src/i18n/en_US.json b/js/src/i18n/en_US.json
index c74629007..c7459ab08 100644
--- a/js/src/i18n/en_US.json
+++ b/js/src/i18n/en_US.json
@@ -1230,5 +1230,7 @@
   "Clear date filter field": "Clear date filter field",
   "{count} members or followers": "No members or followers|One member or follower|{count} members or followers",
   "This profile is from another instance, the informations shown here may be incomplete.": "This profile is from another instance, the informations shown here may be incomplete.",
-  "View full profile": "View full profile"
+  "View full profile": "View full profile",
+  "Any type": "Any type",
+  "In person": "In person"
 }
diff --git a/js/src/i18n/fr_FR.json b/js/src/i18n/fr_FR.json
index d4d2eefaf..1b92dcdfc 100644
--- a/js/src/i18n/fr_FR.json
+++ b/js/src/i18n/fr_FR.json
@@ -1334,5 +1334,7 @@
   "Clear date filter field": "Vider le champ de filtre de la date",
   "{count} members or followers": "Aucun⋅e membre ou abonné⋅e|Un⋅e membre ou abonné⋅e|{count} membres ou abonné⋅es",
   "This profile is from another instance, the informations shown here may be incomplete.": "Ce profil provient d'une autre instance, les informations montrées ici peuvent être incomplètes.",
-  "View full profile": "Voir le profil complet"
+  "View full profile": "Voir le profil complet",
+  "Any type": "N'importe quel type",
+  "In person": "En personne"
 }
diff --git a/js/src/types/event.model.ts b/js/src/types/event.model.ts
index 6f66e085b..35899be18 100644
--- a/js/src/types/event.model.ts
+++ b/js/src/types/event.model.ts
@@ -31,6 +31,8 @@ export interface IEventParticipantStats {
   going: number;
 }
 
+export type EventType = "IN_PERSON" | "ONLINE" | null;
+
 interface IEventEditJSON {
   id?: string;
   title: string;
diff --git a/js/src/views/Search.vue b/js/src/views/Search.vue
index e51e26a7b..e0521cca7 100644
--- a/js/src/views/Search.vue
+++ b/js/src/views/Search.vue
@@ -9,56 +9,84 @@
     <section class="hero is-light" v-else>
       <div class="hero-body">
         <form @submit.prevent="submit()">
-          <b-field :label="$t('Key words')" label-for="search" expanded>
+          <b-field
+            class="searchQuery"
+            :label="$t('Key words')"
+            label-for="search"
+          >
             <b-input
               icon="magnify"
               type="search"
               id="search"
-              size="is-large"
-              expanded
-              v-model="search"
+              :value="search"
+              @input="debouncedUpdateSearchQuery"
               :placeholder="
                 $t('For instance: London, Taekwondo, Architecture…')
               "
             />
           </b-field>
-          <b-field grouped group-multiline position="is-right" expanded>
-            <b-field :label="$t('Location')" label-for="location">
-              <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">
-              <b-select v-model="radius" id="radius" expanded>
-                <option
-                  v-for="(radiusOption, index) in radiusOptions"
-                  :key="index"
-                  :value="radiusOption"
-                >
-                  {{ radiusString(radiusOption) }}
-                </option>
-              </b-select>
-            </b-field>
-            <b-field :label="$t('Date')" label-for="date">
-              <b-select
-                v-model="when"
-                id="date"
-                :disabled="activeTab !== 0"
-                expanded
+          <full-address-auto-complete
+            class="searchLocation"
+            :label="$t('Location')"
+            v-model="location"
+            id="location"
+            ref="aac"
+            :placeholder="$t('For instance: London')"
+            @input="locchange"
+          />
+          <b-field
+            :label="$t('Radius')"
+            label-for="radius"
+            class="searchRadius"
+          >
+            <b-select expanded v-model="radius" id="radius">
+              <option
+                v-for="(radiusOption, index) in radiusOptions"
+                :key="index"
+                :value="radiusOption"
               >
-                <option
-                  v-for="(option, index) in options"
-                  :key="index"
-                  :value="index"
-                >
-                  {{ option.label }}
-                </option>
-              </b-select>
-            </b-field>
+                {{ radiusString(radiusOption) }}
+              </option>
+            </b-select>
+          </b-field>
+          <b-field :label="$t('Date')" label-for="date" class="searchDate">
+            <b-select
+              expanded
+              v-model="when"
+              id="date"
+              :disabled="activeTab !== 0"
+            >
+              <option
+                v-for="(option, index) in dateOptions"
+                :key="index"
+                :value="index"
+              >
+                {{ option.label }}
+              </option>
+            </b-select>
+          </b-field>
+          <b-field
+            expanded
+            :label="$t('Type')"
+            label-for="type"
+            class="searchType"
+          >
+            <b-select
+              expanded
+              v-model="type"
+              id="type"
+              :disabled="activeTab !== 0"
+            >
+              <option :value="null">
+                {{ $t("Any type") }}
+              </option>
+              <option :value="'ONLINE'">
+                {{ $t("Online") }}
+              </option>
+              <option :value="'IN_PERSON'">
+                {{ $t("In person") }}
+              </option>
+            </b-select>
           </b-field>
         </form>
       </div>
@@ -171,16 +199,17 @@ import {
 import { SearchTabs } from "@/types/enums";
 import MultiCard from "../components/Event/MultiCard.vue";
 import { FETCH_EVENTS } from "../graphql/event";
-import { IEvent } from "../types/event.model";
+import { EventType, IEvent } from "../types/event.model";
 import RouteName from "../router/name";
 import { IAddress, Address } from "../types/address.model";
-import AddressAutoComplete from "../components/Event/AddressAutoComplete.vue";
+import FullAddressAutoComplete from "../components/Event/FullAddressAutoComplete.vue";
 import { SEARCH_EVENTS_AND_GROUPS } from "../graphql/search";
 import { Paginate } from "../types/paginate";
 import { IGroup } from "../types/actor";
 import MultiGroupCard from "../components/Group/MultiGroupCard.vue";
 import { CONFIG } from "../graphql/config";
 import { REVERSE_GEOCODE } from "../graphql/address";
+import debounce from "lodash/debounce";
 
 interface ISearchTimeOption {
   label: string;
@@ -198,12 +227,10 @@ 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: {
     MultiCard,
-    AddressAutoComplete,
+    FullAddressAutoComplete,
     MultiGroupCard,
   },
   apollo: {
@@ -217,7 +244,7 @@ const THROTTLE = 2000; // minimum interval in ms between two requests
         };
       },
     },
-    search: {
+    searchElements: {
       query: SEARCH_EVENTS_AND_GROUPS,
       fetchPolicy: "cache-and-network",
       variables() {
@@ -228,15 +255,16 @@ const THROTTLE = 2000; // minimum interval in ms between two requests
           beginsOn: this.start,
           endsOn: this.end,
           radius: this.radius,
-          page: this.eventPage,
+          eventPage: this.eventPage,
+          groupPage: this.groupPage,
           limit: EVENT_PAGE_LIMIT,
+          type: this.type,
         };
       },
       update(data) {
         this.searchEvents = data.searchEvents;
         this.searchGroups = data.searchGroups;
       },
-      throttle: THROTTLE,
     },
   },
   metaInfo() {
@@ -261,11 +289,9 @@ export default class Search extends Vue {
 
   searchGroups: Paginate<IGroup> = { total: 0, elements: [] };
 
-  groupPage = 1;
-
   location: IAddress = new Address();
 
-  options: Record<string, ISearchTimeOption> = {
+  dateOptions: Record<string, ISearchTimeOption> = {
     today: {
       label: this.$t("Today") as string,
       start: new Date(),
@@ -315,9 +341,15 @@ export default class Search extends Vue {
   GROUP_PAGE_LIMIT = GROUP_PAGE_LIMIT;
 
   $refs!: {
-    aac: AddressAutoComplete;
+    aac: FullAddressAutoComplete;
   };
 
+  data(): Record<string, unknown> {
+    return {
+      debouncedUpdateSearchQuery: debounce(this.updateSearchQuery, 200),
+    };
+  }
+
   mounted(): void {
     this.prepareLocation(this.$route.query.geohash as string);
   }
@@ -335,6 +367,10 @@ export default class Search extends Vue {
     this.$apollo.queries.searchEvents.refetch();
   }
 
+  updateSearchQuery(searchQuery: string): void {
+    this.search = searchQuery;
+  }
+
   get eventPage(): number {
     return parseInt(this.$route.query.eventPage as string, 10) || 1;
   }
@@ -346,6 +382,17 @@ export default class Search extends Vue {
     });
   }
 
+  get groupPage(): number {
+    return parseInt(this.$route.query.groupPage as string, 10) || 1;
+  }
+
+  set groupPage(page: number) {
+    this.$router.push({
+      name: this.$route.name || RouteName.SEARCH,
+      query: { ...this.$route.query, groupPage: page.toString() },
+    });
+  }
+
   get search(): string | undefined {
     return this.$route.query.term as string;
   }
@@ -411,6 +458,23 @@ export default class Search extends Vue {
     });
   }
 
+  get type(): EventType {
+    return this.$route.query.type as EventType;
+  }
+
+  set type(type: EventType) {
+    const query = { ...this.$route.query, type };
+    if (type == null) {
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      // @ts-ignore
+      delete query.type;
+    }
+    this.$router.replace({
+      name: RouteName.SEARCH,
+      query,
+    });
+  }
+
   get weekend(): { start: Date; end: Date } {
     const now = new Date();
     const endOfWeekDate = endOfWeek(now, { locale: this.$dateFnsLocale });
@@ -453,22 +517,24 @@ export default class Search extends Vue {
     if (this.radius === undefined || this.radius === null) {
       this.radius = DEFAULT_RADIUS;
     }
-    if (e.geom) {
+    if (e?.geom) {
       const [lon, lat] = e.geom.split(";");
       this.geohash = ngeohash.encode(lat, lon, GEOHASH_DEPTH);
+    } else {
+      this.geohash = undefined;
     }
   };
 
   get start(): Date | undefined {
-    if (this.options[this.when]) {
-      return this.options[this.when].start;
+    if (this.dateOptions[this.when]) {
+      return this.dateOptions[this.when].start;
     }
     return undefined;
   }
 
   get end(): Date | undefined | null {
-    if (this.options[this.when]) {
-      return this.options[this.when].end;
+    if (this.dateOptions[this.when]) {
+      return this.dateOptions[this.when].end;
     }
     return undefined;
   }
@@ -484,6 +550,7 @@ export default class Search extends Vue {
     return (
       this.stringExists(this.search) ||
       this.stringExists(this.tag) ||
+      this.stringExists(this.type) ||
       (this.stringExists(this.geohash) && this.valueExists(this.radius)) ||
       this.valueExists(this.end)
     );
@@ -494,13 +561,14 @@ export default class Search extends Vue {
     return value !== undefined && value !== null;
   }
 
-  private stringExists(value: string | undefined): boolean {
+  private stringExists(value: string | null | undefined): boolean {
     return this.valueExists(value) && (value as string).length > 0;
   }
 }
 </script>
 
 <style scoped lang="scss">
+@import "~bulma/sass/utilities/mixins.sass";
 main > .container {
   background: $white;
 
@@ -526,19 +594,73 @@ h3.title {
 }
 
 form {
-  ::v-deep .field label.label {
-    margin-bottom: 0;
+  // ::v-deep .field label.label {
+  //   margin-bottom: 0;
+  // }
+
+  // .field.is-expanded:last-child > .field-body > .field.is-grouped {
+  //   flex-wrap: wrap;
+  //   flex: 1;
+  //   .field {
+  //     flex: 1 0 auto;
+  //     &:first-child {
+  //       flex: 3 0 300px;
+  //     }
+  //   }
+  // }
+  display: grid;
+  grid-gap: 0 15px;
+  grid-template-areas: "query" "location" "radius" "date" "type";
+
+  & > * {
+    margin-bottom: 0 !important;
   }
 
-  .field.is-expanded:last-child > .field-body > .field.is-grouped {
-    flex-wrap: wrap;
-    flex: 1;
-    .field {
-      flex: 1 0 auto;
-      &:first-child {
-        flex: 3 0 300px;
-      }
+  @include tablet {
+    grid-template-columns: max-content max-content max-content auto;
+    grid-template-areas: "query . ." "location . ." "radius date type";
+  }
+
+  @include desktop {
+    grid-template-columns: max-content max-content max-content 1fr 3fr;
+    grid-template-areas: "query . location" "radius date type";
+  }
+
+  .searchQuery {
+    grid-area: query;
+    @include tablet {
+      grid-column: span 4;
     }
+    @include desktop {
+      grid-column-start: 1;
+      grid-column-end: 4;
+    }
+  }
+
+  .searchLocation {
+    grid-area: location;
+    :v-deep .column {
+      padding-bottom: 0;
+    }
+    @include tablet {
+      grid-column: span 4;
+    }
+    @include desktop {
+      grid-column-start: 4;
+      grid-column-end: 7;
+    }
+  }
+
+  .searchRadius {
+    grid-area: radius;
+  }
+
+  .searchDate {
+    grid-area: date;
+  }
+
+  .searchType {
+    grid-area: type;
   }
 }
 </style>
diff --git a/lib/graphql/schema/search.ex b/lib/graphql/schema/search.ex
index c11856144..db0c7be58 100644
--- a/lib/graphql/schema/search.ex
+++ b/lib/graphql/schema/search.ex
@@ -44,6 +44,15 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
     end)
   end
 
+  enum :event_type do
+    value(:in_person,
+      description:
+        "The event will happen in person. It can also be livestreamed, but has a physical address"
+    )
+
+    value(:online, description: "The event will only happen online. It has no physical address")
+  end
+
   object :search_queries do
     @desc "Search persons"
     field :search_persons, :persons do
@@ -83,6 +92,7 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
       arg(:term, :string, default_value: "")
       arg(:tags, :string, description: "A comma-separated string listing the tags")
       arg(:location, :string, description: "A geohash for coordinates")
+      arg(:type, :event_type, description: "Whether the event is online or in person")
 
       arg(:radius, :float,
         default_value: 50,
diff --git a/lib/mobilizon/events/events.ex b/lib/mobilizon/events/events.ex
index 6960acc98..2b8510170 100644
--- a/lib/mobilizon/events/events.ex
+++ b/lib/mobilizon/events/events.ex
@@ -506,6 +506,7 @@ defmodule Mobilizon.Events do
     |> events_for_ends_on(args)
     |> events_for_tags(args)
     |> events_for_location(args)
+    |> filter_online(args)
     |> filter_draft()
     |> filter_local_or_from_followed_instances_events()
     |> filter_public_visibility()
@@ -1307,6 +1308,18 @@ defmodule Mobilizon.Events do
 
   defp events_for_location(query, _args), do: query
 
+  @spec filter_online(Ecto.Query.t(), map()) :: Ecto.Query.t()
+  defp filter_online(query, %{type: :online}), do: is_online_fragment(query, true)
+
+  defp filter_online(query, %{type: :in_person}), do: is_online_fragment(query, false)
+
+  defp filter_online(query, _), do: query
+
+  @spec is_online_fragment(Ecto.Query.t(), boolean()) :: Ecto.Query.t()
+  defp is_online_fragment(query, value) do
+    where(query, [q], fragment("(?->>'is_online')::bool = ?", q.options, ^value))
+  end
+
   @spec normalize_search_string(String.t()) :: String.t()
   defp normalize_search_string(search_string) do
     search_string