Allow to filter by begins_on and ends_on. Redirect explore to search

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2020-08-05 11:42:23 +02:00
parent d725393fd4
commit b4f500532f
No known key found for this signature in database
GPG key ID: A061B9DDE0CA0773
16 changed files with 354 additions and 248 deletions

View file

@ -25,6 +25,7 @@
"buefy": "^0.8.2",
"bulma-divider": "^0.2.0",
"core-js": "^3.6.4",
"date-fns": "^2.15.0",
"eslint-plugin-cypress": "^2.10.3",
"graphql": "^15.0.0",
"graphql-tag": "^2.10.3",

View file

@ -26,7 +26,7 @@ input.input {
}
.section {
padding: 1rem 2rem 4rem;
padding: 1rem 1% 4rem;
}
figure img.is-rounded {

View file

@ -2,7 +2,7 @@
<b-autocomplete
:data="addressData"
v-model="queryText"
:placeholder="$t('e.g. 10 Rue Jangot')"
:placeholder="placeholder || $t('e.g. 10 Rue Jangot')"
field="fullName"
:loading="isFetching"
@typing="fetchAsyncData"
@ -45,6 +45,7 @@ import { IConfig } from "../../types/config.model";
})
export default class AddressAutoComplete extends Vue {
@Prop({ required: true }) value!: IAddress;
@Prop({ required: false }) placeholder!: string;
addressData: IAddress[] = [];

View file

@ -1,8 +1,22 @@
import gql from "graphql-tag";
export const SEARCH_EVENTS = gql`
query SearchEvents($location: String, $radius: Float, $tags: String, $term: String) {
searchEvents(location: $location, radius: $radius, tags: $tags, term: $term) {
query SearchEvents(
$location: String
$radius: Float
$tags: String
$term: String
$beginsOn: DateTime
$endsOn: DateTime
) {
searchEvents(
location: $location
radius: $radius
tags: $tags
term: $term
beginsOn: $beginsOn
endsOn: $endsOn
) {
total
elements {
title

View file

@ -730,5 +730,18 @@
"Delete post": "Delete post",
"Update post": "Update post",
"Posts": "Posts",
"Register an account on {instanceName}!": "Register an account on {instanceName}!"
"Register an account on {instanceName}!": "Register an account on {instanceName}!",
"Key words": "Key words",
"For instance: London": "For instance: London",
"Radius": "Radius",
"Today": "Today",
"Tomorrow": "Tomorrow",
"This weekend": "This weekend",
"This week": "This week",
"Next week": "Next week",
"This month": "This month",
"Next month": "Next month",
"Any day": "Any day",
"{nb} km": "{nb} km",
"any distance": "any distance"
}

View file

@ -730,5 +730,18 @@
"Delete post": "Supprimer le billet",
"Update post": "Mettre à jour le billet",
"Posts": "Billets",
"Register an account on {instanceName}!": "S'inscrire sur {instanceName} !"
"Register an account on {instanceName}!": "S'inscrire sur {instanceName} !",
"Key words": "Mots clés",
"For instance: London": "Par exemple : Lyon",
"Radius": "Rayon",
"Today": "Aujourd'hui",
"Tomorrow": "Demain",
"This weekend": "Ce weekend",
"This week": "Cette semaine",
"Next week": "La semaine prochaine",
"This month": "Ce mois-ci",
"Next month": "Le mois-prochain",
"Any day": "N'importe quand",
"{nb} km": "{nb} km",
"any distance": "peu importe"
}

View file

@ -10,6 +10,7 @@ import TimeAgo from "javascript-time-ago";
import App from "./App.vue";
import router from "./router";
import { NotifierPlugin } from "./plugins/notifier";
import { DateFnsPlugin } from "./plugins/dateFns";
import filters from "./filters";
import { i18n } from "./utils/i18n";
import messages from "./i18n";
@ -31,6 +32,7 @@ import(`javascript-time-ago/locale/${locale}`).then((localeFile) => {
Vue.use(Buefy);
Vue.use(NotifierPlugin);
Vue.use(DateFnsPlugin, { locale });
Vue.use(filters);
Vue.use(VueMeta);
Vue.use(VueScrollTo);

14
js/src/plugins/dateFns.ts Normal file
View file

@ -0,0 +1,14 @@
import Vue from "vue";
import Locale from "date-fns";
declare module "vue/types/vue" {
interface Vue {
$dateFnsLocale: Locale;
}
}
export function DateFnsPlugin(vue: typeof Vue, { locale }: { locale: string }): void {
import(`date-fns/locale/${locale}/index.js`).then((localeEntity) => {
Vue.prototype.$dateFnsLocale = localeEntity;
});
}

View file

@ -8,7 +8,6 @@ const participations = () =>
const editEvent = () => import(/* webpackChunkName: "edit-event" */ "@/views/Event/Edit.vue");
const event = () => import(/* webpackChunkName: "event" */ "@/views/Event/Event.vue");
const myEvents = () => import(/* webpackChunkName: "my-events" */ "@/views/Event/MyEvents.vue");
const explore = () => import(/* webpackChunkName: "explore" */ "@/views/Event/Explore.vue");
export enum EventRouteName {
EVENT_LIST = "EventList",
@ -43,7 +42,7 @@ export const eventRoutes: RouteConfig[] = [
{
path: "/events/explore",
name: EventRouteName.EXPLORE,
component: explore,
redirect: { name: "Search" },
meta: { requiredAuth: false },
},
{

View file

@ -49,7 +49,7 @@ const router = new Router({
...discussionRoutes,
...errorRoutes,
{
path: "/search/:searchTerm/:searchType?",
path: "/search/:searchTerm?/:searchType?",
name: RouteName.SEARCH,
component: Search,
props: true,

View file

@ -1,116 +0,0 @@
<template>
<div class="section container">
<h1 class="title">{{ $t("Explore") }}</h1>
<section class="hero">
<div class="hero-body">
<form @submit.prevent="submit()">
<b-field
:label="$t('Event')"
grouped
group-multiline
label-position="on-border"
label-for="search"
>
<b-input
icon="magnify"
type="search"
id="search"
size="is-large"
expanded
v-model="searchTerm"
:placeholder="$t('For instance: London, Taekwondo, Architecture…')"
/>
<p class="control">
<b-button
@click="submit"
type="is-info"
size="is-large"
v-bind:disabled="searchTerm.trim().length === 0"
>{{ $t("Search") }}</b-button
>
</p>
</b-field>
</form>
</div>
</section>
<section class="events-featured">
<b-loading :active.sync="$apollo.loading"></b-loading>
<h2 class="title">{{ $t("Featured events") }}</h2>
<div v-if="events.length > 0" class="columns is-multiline">
<div class="column is-one-third-desktop" v-for="event in events" :key="event.uuid">
<EventCard :event="event" />
</div>
</div>
<b-message v-else-if="events.length === 0 && $apollo.loading === false" type="is-danger">{{
$t("No events found")
}}</b-message>
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import EventCard from "@/components/Event/EventCard.vue";
import { FETCH_EVENTS } from "@/graphql/event";
import { IEvent } from "@/types/event.model";
import RouteName from "../../router/name";
@Component({
components: {
EventCard,
},
apollo: {
events: {
query: FETCH_EVENTS,
},
},
metaInfo() {
return {
// if no subcomponents specify a metaInfo.title, this title will be used
title: this.$t("Explore") as string,
// all titles will be injected into this template
titleTemplate: "%s | Mobilizon",
};
},
})
export default class Explore extends Vue {
events: IEvent[] = [];
searchTerm = "";
submit() {
this.$router.push({
name: RouteName.SEARCH,
params: { searchTerm: this.searchTerm },
});
}
}
</script>
<style scoped lang="scss">
@import "@/variables.scss";
main > .container {
background: $white;
.hero-body {
padding: 1rem 1.5rem;
}
}
h1.title {
margin-top: 1.5rem;
}
h3.title {
margin-bottom: 1.5rem;
}
.events-featured {
margin: 25px auto;
.columns {
margin: 1rem auto 3rem;
}
}
</style>

View file

@ -1,16 +1,62 @@
<template>
<section class="container">
<form @submit.prevent="processSearch" v-if="!actualTag">
<b-field :label="$t('Event')">
<b-input size="is-large" v-model="search" />
<div class="section container">
<h1 class="title">{{ $t("Explore") }}</h1>
<section class="hero is-light">
<div class="hero-body">
<form @submit.prevent="submit()">
<b-field :label="$t('Key words')" label-for="search" expanded>
<b-input
icon="magnify"
type="search"
id="search"
size="is-large"
expanded
v-model="search"
: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"
:placeholder="$t('For instance: London')"
/>
</b-field>
<b-field :label="$t('Radius')" label-for="radius">
<b-select v-model="radius" id="radius">
<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">
<option v-for="(option, index) in options" :key="index" :value="option">{{
option.label
}}</option>
</b-select>
</b-field>
<b-field :label="$t('Location')">
<address-auto-complete v-model="location" />
</b-field>
<b-button native-type="submit">{{ $t("Go") }}</b-button>
</form>
<b-loading :active.sync="$apollo.loading" />
<b-tabs v-model="activeTab" type="is-boxed" class="searchTabs" @change="changeTab">
</div>
</section>
<section class="events-featured" v-if="searchEvents.initial">
<b-loading :active.sync="$apollo.loading"></b-loading>
<h2 class="title">{{ $t("Featured events") }}</h2>
<div v-if="events.length > 0" class="columns is-multiline">
<div class="column is-one-third-desktop" v-for="event in events" :key="event.uuid">
<EventCard :event="event" />
</div>
</div>
<b-message v-else-if="events.length === 0 && $apollo.loading === false" type="is-danger">{{
$t("No events found")
}}</b-message>
</section>
<b-tabs v-else v-model="activeTab" type="is-boxed" class="searchTabs">
<b-tab-item>
<template slot="header">
<b-icon icon="calendar"></b-icon>
@ -32,43 +78,45 @@
$t("No events found")
}}</b-message>
</b-tab-item>
<!-- <b-tab-item>-->
<!-- <template slot="header">-->
<!-- <b-icon icon="account-multiple"></b-icon>-->
<!-- <span>-->
<!-- {{ $t('Groups') }} <b-tag rounded>{{ searchGroups.total }}</b-tag>-->
<!-- </span>-->
<!-- </template>-->
<!-- <div v-if="searchGroups.total > 0" class="columns is-multiline">-->
<!-- <div class="column is-one-quarter-desktop is-half-mobile"-->
<!-- v-for="group in groups"-->
<!-- :key="group.uuid">-->
<!-- <group-card :group="group" />-->
<!-- </div>-->
<!-- </div>-->
<!-- <b-message v-else-if="$apollo.loading === false" type="is-danger">-->
<!-- {{ $t('No groups found') }}-->
<!-- </b-message>-->
<!-- </b-tab-item>-->
</b-tabs>
</section>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { SEARCH_EVENTS, SEARCH_GROUPS } from "../graphql/search";
import RouteName from "../router/name";
import EventCard from "../components/Event/EventCard.vue";
import GroupCard from "../components/Group/GroupCard.vue";
import AddressAutoComplete from "../components/Event/AddressAutoComplete.vue";
import { Group, IGroup } from "../types/actor";
import { FETCH_EVENTS } from "../graphql/event";
import { IEvent } from "../types/event.model";
import RouteName from "../router/name";
import { IAddress, Address } from "../types/address.model";
import { SearchEvent, SearchGroup } from "../types/search.model";
import AddressAutoComplete from "../components/Event/AddressAutoComplete.vue";
import ngeohash from "ngeohash";
import { SEARCH_EVENTS, SEARCH_GROUPS } from "../graphql/search";
import { Paginate } from "../types/paginate";
import {
endOfToday,
addDays,
startOfDay,
endOfDay,
endOfWeek,
addWeeks,
startOfWeek,
endOfMonth,
addMonths,
startOfMonth,
eachWeekendOfInterval,
} from "date-fns";
interface ISearchTimeOption {
label: string;
start?: Date;
end?: Date | null;
}
enum SearchTabs {
EVENTS = 0,
GROUPS = 1,
PERSONS = 2, // not used right now
}
const tabsName: { events: number; groups: number } = {
@ -77,7 +125,12 @@ const tabsName: { events: number; groups: number } = {
};
@Component({
components: {
EventCard,
AddressAutoComplete,
},
apollo: {
events: FETCH_EVENTS,
searchEvents: {
query: SEARCH_EVENTS,
variables() {
@ -85,105 +138,117 @@ const tabsName: { events: number; groups: number } = {
term: this.search,
tags: this.actualTag,
location: this.geohash,
beginsOn: this.start,
endsOn: this.end,
radius: this.radius,
};
},
debounce: 300,
skip() {
return !this.search && !this.actualTag;
return !this.search && !this.actualTag && !this.geohash && this.end === null;
},
},
searchGroups: {
query: SEARCH_GROUPS,
variables() {
},
metaInfo() {
return {
searchText: this.search,
// if no subcomponents specify a metaInfo.title, this title will be used
title: this.$t("Explore events") as string,
// all titles will be injected into this template
titleTemplate: "%s | Mobilizon",
};
},
skip() {
return !this.search || this.isURL(this.search);
},
},
},
components: {
GroupCard,
EventCard,
AddressAutoComplete,
},
})
export default class Search extends Vue {
@Prop({ type: String, required: false }) searchTerm!: string;
@Prop({ type: String, required: false }) tag!: string;
@Prop({ type: String, required: false, default: "" }) searchTerm!: string;
@Prop({ type: String, required: false, default: "events" }) searchType!: "events" | "groups";
searchEvents: SearchEvent = { total: 0, elements: [] };
events: IEvent[] = [];
searchGroups: SearchGroup = { total: 0, elements: [] };
searchEvents: Paginate<IEvent> & { initial: boolean } = { total: 0, elements: [], initial: true };
search = this.searchTerm;
activeTab: SearchTabs = tabsName[this.searchType];
search: string = this.searchTerm;
actualTag: string = this.tag;
location: IAddress = new Address();
@Watch("searchEvents")
async redirectURLToEvent() {
if (this.searchEvents.total === 1 && this.isURL(this.searchTerm)) {
return await this.$router.replace({
name: RouteName.EVENT,
params: { uuid: this.searchEvents.elements[0].uuid },
});
}
}
options: ISearchTimeOption[] = [
{
label: this.$t("Today") as string,
start: new Date(),
end: endOfToday(),
},
{
label: this.$t("Tomorrow") as string,
start: startOfDay(addDays(new Date(), 1)),
end: endOfDay(addDays(new Date(), 1)),
},
{
label: this.$t("This weekend") as string,
start: this.weekend.start,
end: this.weekend.end,
},
{
label: this.$t("This week") as string,
start: new Date(),
end: endOfWeek(new Date(), { locale: this.$dateFnsLocale }),
},
{
label: this.$t("Next week") as string,
start: startOfWeek(addWeeks(new Date(), 1), { locale: this.$dateFnsLocale }),
end: endOfWeek(addWeeks(new Date(), 1), { locale: this.$dateFnsLocale }),
},
{
label: this.$t("This month") as string,
start: new Date(),
end: endOfMonth(new Date()),
},
{
label: this.$t("Next month") as string,
start: startOfMonth(addMonths(new Date(), 1)),
end: endOfMonth(addMonths(new Date(), 1)),
},
{
label: this.$t("Any day") as string,
start: undefined,
end: undefined,
},
];
changeTab(index: number) {
switch (index) {
case SearchTabs.EVENTS:
this.$router.push({
name: RouteName.SEARCH,
params: { searchTerm: this.searchTerm, searchType: "events" },
});
break;
case SearchTabs.GROUPS:
this.$router.push({
name: RouteName.SEARCH,
params: { searchTerm: this.searchTerm, searchType: "groups" },
});
break;
}
}
when: ISearchTimeOption = {
label: this.$t("Any day") as string,
start: undefined,
end: null,
};
@Watch("search")
changeTabForResult() {
if (this.searchEvents.total === 0 && this.searchGroups.total > 0) {
this.activeTab = SearchTabs.GROUPS;
}
if (this.searchGroups.total === 0 && this.searchEvents.total > 0) {
this.activeTab = SearchTabs.EVENTS;
}
radiusString = (radius: number | null) => {
if (radius) {
return this.$tc("{nb} km", radius, { nb: radius });
}
return this.$t("any distance");
};
@Watch("search")
@Watch("$route")
async loadSearch() {
(await this.$apollo.queries.searchEvents.refetch()) &&
this.$apollo.queries.searchGroups.refetch();
}
radiusOptions: (number | null)[] = [1, 5, 10, 25, 50, 100, 150, null];
get groups(): IGroup[] {
return this.searchGroups.elements.map((group) => Object.assign(new Group(), group));
}
radius: number | undefined = undefined;
isURL(url: string): boolean {
const a = document.createElement("a");
a.href = url;
return (a.host && a.host !== window.location.host) as boolean;
}
processSearch() {
submit() {
this.$apollo.queries.searchEvents.refetch();
}
@Watch("searchTerm")
updateSearchTerm() {
this.search = this.searchTerm;
}
get weekend(): { start: Date; end: Date } {
const now = new Date();
const endOfWeekDate = endOfWeek(now, { locale: this.$dateFnsLocale });
const startOfWeekDate = startOfWeek(now, { locale: this.$dateFnsLocale });
const [start, end] = eachWeekendOfInterval({ start: startOfWeekDate, end: endOfWeekDate });
return { start: startOfDay(start), end: endOfDay(end) };
}
get geohash() {
if (this.location && this.location.geom) {
const [lon, lat] = this.location.geom.split(";");
@ -191,16 +256,47 @@ export default class Search extends Vue {
}
return undefined;
}
get start(): Date | undefined {
return this.when.start;
}
get end(): Date | undefined | null {
return this.when.end;
}
}
</script>
<style lang="scss">
@import "~bulma/sass/utilities/_all";
@import "~bulma/sass/components/tabs";
@import "~buefy/src/scss/components/tabs";
@import "~bulma/sass/elements/tag";
.searchTabs .tab-content {
background: #fff;
min-height: 10em;
<style scoped lang="scss">
@import "@/variables.scss";
main > .container {
background: $white;
.hero-body {
padding: 1rem 1.5rem;
}
}
h1.title {
margin-top: 1.5rem;
}
h3.title {
margin-bottom: 1.5rem;
}
.events-featured {
margin: 25px auto;
.columns {
margin: 1rem auto 3rem;
}
}
form {
/deep/ .field label.label {
margin-bottom: 0;
}
}
</style>

View file

@ -4406,6 +4406,11 @@ date-fns@^1.27.2:
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c"
integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==
date-fns@^2.15.0:
version "2.15.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.15.0.tgz#424de6b3778e4e69d3ff27046ec136af58ae5d5f"
integrity sha512-ZCPzAMJZn3rNUvvQIMlXhDr4A+Ar07eLeGsGREoWU19a3Pqf5oYa+ccd+B3F6XVtQY6HANMFdOQ8A+ipFnvJdQ==
de-indent@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d"

View file

@ -51,6 +51,8 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
arg(:radius, :float, default_value: 50)
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
arg(:begins_on, :datetime)
arg(:ends_on, :datetime)
resolve(&Search.search_events/3)
end

View file

@ -463,9 +463,10 @@ defmodule Mobilizon.Events do
term
|> normalize_search_string()
|> events_for_search_query()
|> events_for_begins_on(args)
|> events_for_ends_on(args)
|> events_for_tags(args)
|> events_for_location(args)
|> filter_future_events(true)
|> filter_local_or_from_followed_instances_events()
|> order_by([q], asc: q.id)
|> Page.build_page(page, limit)
@ -1296,6 +1297,29 @@ defmodule Mobilizon.Events do
)
end
@spec events_for_begins_on(Ecto.Query.t(), map()) :: Ecto.Query.t()
defp events_for_begins_on(query, args) do
begins_on = Map.get(args, :begins_on, DateTime.utc_now())
query
|> where([q], q.begins_on >= ^begins_on)
end
@spec events_for_ends_on(Ecto.Query.t(), map()) :: Ecto.Query.t()
defp events_for_ends_on(query, args) do
ends_on = Map.get(args, :ends_on)
if is_nil(ends_on),
do: query,
else:
where(
query,
[q],
(is_nil(q.ends_on) and q.begins_on <= ^ends_on) or
q.ends_on <= ^ends_on
)
end
@spec events_for_tags(Ecto.Query.t(), map()) :: Ecto.Query.t()
defp events_for_tags(query, %{tags: tags}) when is_valid_string?(tags) do
query
@ -1307,6 +1331,9 @@ defmodule Mobilizon.Events do
defp events_for_tags(query, _args), do: query
@spec events_for_location(Ecto.Query.t(), map()) :: Ecto.Query.t()
defp events_for_location(query, %{radius: radius}) when is_nil(radius),
do: query
defp events_for_location(query, %{location: location, radius: radius})
when is_valid_string?(location) and not is_nil(radius) do
with {lon, lat} <- Geohax.decode(location),

View file

@ -15,8 +15,8 @@ defmodule Mobilizon.GraphQL.Resolvers.SearchTest do
describe "search events/3" do
@search_events_query """
query SearchEvents($location: String, $radius: Float, $tags: String, $term: String) {
searchEvents(location: $location, radius: $radius, tags: $tags, term: $term) {
query SearchEvents($location: String, $radius: Float, $tags: String, $term: String, $beginsOn: DateTime, $endsOn: DateTime) {
searchEvents(location: $location, radius: $radius, tags: $tags, term: $term, beginsOn: $beginsOn, endsOn: $endsOn) {
total,
elements {
id
@ -145,6 +145,41 @@ defmodule Mobilizon.GraphQL.Resolvers.SearchTest do
event.uuid
end
test "finds events by begins_on and ends_on", %{conn: conn} do
now = DateTime.utc_now()
# TODO
event =
insert(:event,
title: "Tour du monde",
begins_on: DateTime.add(now, 3600 * 24 * 3),
ends_on: DateTime.add(now, 3600 * 24 * 10)
)
insert(:event,
title: "Autre événement",
begins_on: DateTime.add(now, 3600 * 24 * 30),
ends_on: nil
)
Workers.BuildSearch.insert_search_event(event)
res =
AbsintheHelpers.graphql_query(conn,
query: @search_events_query,
variables: %{
beginsOn: now |> DateTime.add(86_400) |> DateTime.to_iso8601(),
endsOn: now |> DateTime.add(1_728_000) |> DateTime.to_iso8601()
}
)
assert res["errors"] == nil
assert res["data"]["searchEvents"]["total"] == 1
assert hd(res["data"]["searchEvents"]["elements"])["uuid"] ==
event.uuid
end
test "finds events with multiple criteria", %{conn: conn} do
{lon, lat} = {45.75, 4.85}
point = %Geo.Point{coordinates: {lon, lat}, srid: 4326}