Merge branch 'bugs' into 'master'

Allow to search events by online status

See merge request framasoft/mobilizon!1098
This commit is contained in:
Thomas Citharel 2021-11-06 14:13:23 +00:00
commit 4f739d8672
11 changed files with 276 additions and 108 deletions

View File

@ -39,34 +39,36 @@
/> />
</div> </div>
<div class="media-content"> <div class="media-content">
<p class="event-title" :title="event.title">{{ event.title }}</p> <h3 class="event-title" :title="event.title">{{ event.title }}</h3>
<div class="event-organizer"> <div class="content-end">
<figure <div class="event-organizer">
class="image is-24x24" <figure
v-if="organizer(event) && organizer(event).avatar" class="image is-24x24"
v-if="organizer(event) && organizer(event).avatar"
>
<img
class="is-rounded"
:src="organizer(event).avatar.url"
alt=""
/>
</figure>
<b-icon v-else icon="account-circle" />
<span class="organizer-name">
{{ organizerDisplayName(event) }}
</span>
</div>
<inline-address
v-if="event.physicalAddress"
class="event-subtitle"
:physical-address="event.physicalAddress"
/>
<div
class="event-subtitle"
v-else-if="event.options && event.options.isOnline"
> >
<img <b-icon icon="video" />
class="is-rounded" <span>{{ $t("Online") }}</span>
:src="organizer(event).avatar.url" </div>
alt=""
/>
</figure>
<b-icon v-else icon="account-circle" />
<span class="organizer-name">
{{ organizerDisplayName(event) }}
</span>
</div>
<inline-address
v-if="event.physicalAddress"
class="event-subtitle"
:physical-address="event.physicalAddress"
/>
<div
class="event-subtitle"
v-else-if="event.options && event.options.isOnline"
>
<b-icon icon="video" />
<span>{{ $t("Online") }}</span>
</div> </div>
</div> </div>
</div> </div>
@ -201,12 +203,14 @@ a.card {
} }
.card-content { .card-content {
height: 100%;
padding: 0.5rem; padding: 0.5rem;
& > .media { & > .media {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
& > .media-left { & > .media-left {
margin-top: -15px; margin-top: -15px;
@ -222,6 +226,9 @@ a.card {
flex: 1; flex: 1;
width: 100%; width: 100%;
overflow-x: inherit; overflow-x: inherit;
display: flex;
flex-direction: column;
justify-content: space-between;
} }
} }

View File

@ -27,9 +27,9 @@ export default class MultiCard extends Vue {
.multi-card-event { .multi-card-event {
display: grid; display: grid;
grid-auto-rows: 1fr; grid-auto-rows: 1fr;
grid-column-gap: 30px; grid-column-gap: 20px;
grid-row-gap: 30px; grid-row-gap: 30px;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
.event-card { .event-card {
height: 100%; height: 100%;
display: flex; display: flex;

View File

@ -15,7 +15,7 @@
</figure> </figure>
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="media mb-3"> <div class="media mb-2">
<div class="media-left"> <div class="media-left">
<figure class="image is-48x48" v-if="group.avatar"> <figure class="image is-48x48" v-if="group.avatar">
<img class="is-rounded" :src="group.avatar.url" alt="" /> <img class="is-rounded" :src="group.avatar.url" alt="" />
@ -24,12 +24,12 @@
</div> </div>
<div class="media-content"> <div class="media-content">
<h3 class="is-size-5 group-title">{{ displayName(group) }}</h3> <h3 class="is-size-5 group-title">{{ displayName(group) }}</h3>
<span class="is-6 has-text-grey-dark"> <span class="is-6 has-text-grey-dark group-federated-username">
{{ `@${usernameWithDomain(group)}` }} {{ `@${usernameWithDomain(group)}` }}
</span> </span>
</div> </div>
</div> </div>
<div class="content" v-html="group.summary" /> <div class="content mb-2" v-html="group.summary" />
<div class="card-custom-footer"> <div class="card-custom-footer">
<inline-address <inline-address
class="has-text-grey-dark" class="has-text-grey-dark"
@ -37,6 +37,7 @@
:physicalAddress="group.physicalAddress" :physicalAddress="group.physicalAddress"
/> />
<p class="has-text-grey-dark"> <p class="has-text-grey-dark">
<b-icon icon="account" />
{{ {{
$tc( $tc(
"{count} members or followers", "{count} members or followers",
@ -117,13 +118,16 @@ export default class GroupCard extends Vue {
text-overflow: ellipsis; text-overflow: ellipsis;
.group-title { .group-title {
line-height: 1.75rem; line-height: 1.5rem;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
font-weight: bold; font-weight: bold;
} }
.group-federated-username {
font-size: 14px;
}
} }
} }
} }

View File

@ -158,8 +158,6 @@ export const FETCH_EVENTS = gql`
url url
} }
publishAt publishAt
# online_address,
# phone_address,
physicalAddress { physicalAddress {
...AdressFragment ...AdressFragment
} }

View File

@ -1,6 +1,7 @@
import gql from "graphql-tag"; import gql from "graphql-tag";
import { ACTOR_FRAGMENT } from "./actor"; import { ACTOR_FRAGMENT } from "./actor";
import { ADDRESS_FRAGMENT } from "./address"; import { ADDRESS_FRAGMENT } from "./address";
import { EVENT_OPTIONS_FRAGMENT } from "./event_options";
import { TAG_FRAGMENT } from "./tags"; import { TAG_FRAGMENT } from "./tags";
export const SEARCH_EVENTS_AND_GROUPS = gql` export const SEARCH_EVENTS_AND_GROUPS = gql`
@ -9,9 +10,11 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
$radius: Float $radius: Float
$tags: String $tags: String
$term: String $term: String
$type: EventType
$beginsOn: DateTime $beginsOn: DateTime
$endsOn: DateTime $endsOn: DateTime
$page: Int $eventPage: Int
$groupPage: Int
$limit: Int $limit: Int
) { ) {
searchEvents( searchEvents(
@ -19,9 +22,10 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
radius: $radius radius: $radius
tags: $tags tags: $tags
term: $term term: $term
type: $type
beginsOn: $beginsOn beginsOn: $beginsOn
endsOn: $endsOn endsOn: $endsOn
page: $page page: $eventPage
limit: $limit limit: $limit
) { ) {
total total
@ -46,6 +50,9 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
attributedTo { attributedTo {
...ActorFragment ...ActorFragment
} }
options {
...EventOptions
}
__typename __typename
} }
} }
@ -53,7 +60,7 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
term: $term term: $term
location: $location location: $location
radius: $radius radius: $radius
page: $page page: $groupPage
limit: $limit limit: $limit
) { ) {
total total
@ -75,6 +82,7 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
} }
} }
} }
${EVENT_OPTIONS_FRAGMENT}
${TAG_FRAGMENT} ${TAG_FRAGMENT}
${ADDRESS_FRAGMENT} ${ADDRESS_FRAGMENT}
${ACTOR_FRAGMENT} ${ACTOR_FRAGMENT}

View File

@ -1230,5 +1230,7 @@
"Clear date filter field": "Clear date filter field", "Clear date filter field": "Clear date filter field",
"{count} members or followers": "No members or followers|One member or follower|{count} members or followers", "{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.", "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"
} }

View File

@ -1334,5 +1334,7 @@
"Clear date filter field": "Vider le champ de filtre de la date", "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", "{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.", "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"
} }

View File

@ -31,6 +31,8 @@ export interface IEventParticipantStats {
going: number; going: number;
} }
export type EventType = "IN_PERSON" | "ONLINE" | null;
interface IEventEditJSON { interface IEventEditJSON {
id?: string; id?: string;
title: string; title: string;

View File

@ -9,56 +9,84 @@
<section class="hero is-light" v-else> <section class="hero is-light" v-else>
<div class="hero-body"> <div class="hero-body">
<form @submit.prevent="submit()"> <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 <b-input
icon="magnify" icon="magnify"
type="search" type="search"
id="search" id="search"
size="is-large" :value="search"
expanded @input="debouncedUpdateSearchQuery"
v-model="search"
:placeholder=" :placeholder="
$t('For instance: London, Taekwondo, Architecture…') $t('For instance: London, Taekwondo, Architecture…')
" "
/> />
</b-field> </b-field>
<b-field grouped group-multiline position="is-right" expanded> <full-address-auto-complete
<b-field :label="$t('Location')" label-for="location"> class="searchLocation"
<address-auto-complete :label="$t('Location')"
v-model="location" v-model="location"
id="location" id="location"
ref="aac" ref="aac"
:placeholder="$t('For instance: London')" :placeholder="$t('For instance: London')"
@input="locchange" @input="locchange"
/> />
</b-field> <b-field
<b-field :label="$t('Radius')" label-for="radius"> :label="$t('Radius')"
<b-select v-model="radius" id="radius" expanded> label-for="radius"
<option class="searchRadius"
v-for="(radiusOption, index) in radiusOptions" >
:key="index" <b-select expanded v-model="radius" id="radius">
:value="radiusOption" <option
> v-for="(radiusOption, index) in radiusOptions"
{{ radiusString(radiusOption) }} :key="index"
</option> :value="radiusOption"
</b-select>
</b-field>
<b-field :label="$t('Date')" label-for="date">
<b-select
v-model="when"
id="date"
:disabled="activeTab !== 0"
expanded
> >
<option {{ radiusString(radiusOption) }}
v-for="(option, index) in options" </option>
:key="index" </b-select>
:value="index" </b-field>
> <b-field :label="$t('Date')" label-for="date" class="searchDate">
{{ option.label }} <b-select
</option> expanded
</b-select> v-model="when"
</b-field> 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> </b-field>
</form> </form>
</div> </div>
@ -171,16 +199,17 @@ import {
import { SearchTabs } from "@/types/enums"; import { SearchTabs } from "@/types/enums";
import MultiCard from "../components/Event/MultiCard.vue"; import MultiCard from "../components/Event/MultiCard.vue";
import { FETCH_EVENTS } from "../graphql/event"; 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 RouteName from "../router/name";
import { IAddress, Address } from "../types/address.model"; 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 { SEARCH_EVENTS_AND_GROUPS } from "../graphql/search";
import { Paginate } from "../types/paginate"; import { Paginate } from "../types/paginate";
import { IGroup } from "../types/actor"; import { IGroup } from "../types/actor";
import MultiGroupCard from "../components/Group/MultiGroupCard.vue"; import MultiGroupCard from "../components/Group/MultiGroupCard.vue";
import { CONFIG } from "../graphql/config"; import { CONFIG } from "../graphql/config";
import { REVERSE_GEOCODE } from "../graphql/address"; import { REVERSE_GEOCODE } from "../graphql/address";
import debounce from "lodash/debounce";
interface ISearchTimeOption { interface ISearchTimeOption {
label: string; 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 GEOHASH_DEPTH = 9; // put enough accuracy, radius will be used anyway
const THROTTLE = 2000; // minimum interval in ms between two requests
@Component({ @Component({
components: { components: {
MultiCard, MultiCard,
AddressAutoComplete, FullAddressAutoComplete,
MultiGroupCard, MultiGroupCard,
}, },
apollo: { apollo: {
@ -217,7 +244,7 @@ const THROTTLE = 2000; // minimum interval in ms between two requests
}; };
}, },
}, },
search: { searchElements: {
query: SEARCH_EVENTS_AND_GROUPS, query: SEARCH_EVENTS_AND_GROUPS,
fetchPolicy: "cache-and-network", fetchPolicy: "cache-and-network",
variables() { variables() {
@ -228,15 +255,16 @@ const THROTTLE = 2000; // minimum interval in ms between two requests
beginsOn: this.start, beginsOn: this.start,
endsOn: this.end, endsOn: this.end,
radius: this.radius, radius: this.radius,
page: this.eventPage, eventPage: this.eventPage,
groupPage: this.groupPage,
limit: EVENT_PAGE_LIMIT, limit: EVENT_PAGE_LIMIT,
type: this.type,
}; };
}, },
update(data) { update(data) {
this.searchEvents = data.searchEvents; this.searchEvents = data.searchEvents;
this.searchGroups = data.searchGroups; this.searchGroups = data.searchGroups;
}, },
throttle: THROTTLE,
}, },
}, },
metaInfo() { metaInfo() {
@ -261,11 +289,9 @@ export default class Search extends Vue {
searchGroups: Paginate<IGroup> = { total: 0, elements: [] }; searchGroups: Paginate<IGroup> = { total: 0, elements: [] };
groupPage = 1;
location: IAddress = new Address(); location: IAddress = new Address();
options: Record<string, ISearchTimeOption> = { dateOptions: Record<string, ISearchTimeOption> = {
today: { today: {
label: this.$t("Today") as string, label: this.$t("Today") as string,
start: new Date(), start: new Date(),
@ -315,9 +341,15 @@ export default class Search extends Vue {
GROUP_PAGE_LIMIT = GROUP_PAGE_LIMIT; GROUP_PAGE_LIMIT = GROUP_PAGE_LIMIT;
$refs!: { $refs!: {
aac: AddressAutoComplete; aac: FullAddressAutoComplete;
}; };
data(): Record<string, unknown> {
return {
debouncedUpdateSearchQuery: debounce(this.updateSearchQuery, 200),
};
}
mounted(): void { mounted(): void {
this.prepareLocation(this.$route.query.geohash as string); this.prepareLocation(this.$route.query.geohash as string);
} }
@ -335,6 +367,10 @@ export default class Search extends Vue {
this.$apollo.queries.searchEvents.refetch(); this.$apollo.queries.searchEvents.refetch();
} }
updateSearchQuery(searchQuery: string): void {
this.search = searchQuery;
}
get eventPage(): number { get eventPage(): number {
return parseInt(this.$route.query.eventPage as string, 10) || 1; 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 { get search(): string | undefined {
return this.$route.query.term as string; 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 } { get weekend(): { start: Date; end: Date } {
const now = new Date(); const now = new Date();
const endOfWeekDate = endOfWeek(now, { locale: this.$dateFnsLocale }); const endOfWeekDate = endOfWeek(now, { locale: this.$dateFnsLocale });
@ -453,22 +517,24 @@ export default class Search extends Vue {
if (this.radius === undefined || this.radius === null) { if (this.radius === undefined || this.radius === null) {
this.radius = DEFAULT_RADIUS; this.radius = DEFAULT_RADIUS;
} }
if (e.geom) { if (e?.geom) {
const [lon, lat] = e.geom.split(";"); const [lon, lat] = e.geom.split(";");
this.geohash = ngeohash.encode(lat, lon, GEOHASH_DEPTH); this.geohash = ngeohash.encode(lat, lon, GEOHASH_DEPTH);
} else {
this.geohash = undefined;
} }
}; };
get start(): Date | undefined { get start(): Date | undefined {
if (this.options[this.when]) { if (this.dateOptions[this.when]) {
return this.options[this.when].start; return this.dateOptions[this.when].start;
} }
return undefined; return undefined;
} }
get end(): Date | undefined | null { get end(): Date | undefined | null {
if (this.options[this.when]) { if (this.dateOptions[this.when]) {
return this.options[this.when].end; return this.dateOptions[this.when].end;
} }
return undefined; return undefined;
} }
@ -484,6 +550,7 @@ export default class Search extends Vue {
return ( return (
this.stringExists(this.search) || this.stringExists(this.search) ||
this.stringExists(this.tag) || this.stringExists(this.tag) ||
this.stringExists(this.type) ||
(this.stringExists(this.geohash) && this.valueExists(this.radius)) || (this.stringExists(this.geohash) && this.valueExists(this.radius)) ||
this.valueExists(this.end) this.valueExists(this.end)
); );
@ -494,13 +561,14 @@ export default class Search extends Vue {
return value !== undefined && value !== null; 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; return this.valueExists(value) && (value as string).length > 0;
} }
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@import "~bulma/sass/utilities/mixins.sass";
main > .container { main > .container {
background: $white; background: $white;
@ -526,19 +594,73 @@ h3.title {
} }
form { form {
::v-deep .field label.label { // ::v-deep .field label.label {
margin-bottom: 0; // 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 { @include tablet {
flex-wrap: wrap; grid-template-columns: max-content max-content max-content auto;
flex: 1; grid-template-areas: "query . ." "location . ." "radius date type";
.field { }
flex: 1 0 auto;
&:first-child { @include desktop {
flex: 3 0 300px; 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> </style>

View File

@ -44,6 +44,15 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
end) end)
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 object :search_queries do
@desc "Search persons" @desc "Search persons"
field :search_persons, :persons do field :search_persons, :persons do
@ -83,6 +92,7 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
arg(:term, :string, default_value: "") arg(:term, :string, default_value: "")
arg(:tags, :string, description: "A comma-separated string listing the tags") arg(:tags, :string, description: "A comma-separated string listing the tags")
arg(:location, :string, description: "A geohash for coordinates") arg(:location, :string, description: "A geohash for coordinates")
arg(:type, :event_type, description: "Whether the event is online or in person")
arg(:radius, :float, arg(:radius, :float,
default_value: 50, default_value: 50,

View File

@ -506,6 +506,7 @@ defmodule Mobilizon.Events do
|> events_for_ends_on(args) |> events_for_ends_on(args)
|> events_for_tags(args) |> events_for_tags(args)
|> events_for_location(args) |> events_for_location(args)
|> filter_online(args)
|> filter_draft() |> filter_draft()
|> filter_local_or_from_followed_instances_events() |> filter_local_or_from_followed_instances_events()
|> filter_public_visibility() |> filter_public_visibility()
@ -1307,6 +1308,18 @@ defmodule Mobilizon.Events do
defp events_for_location(query, _args), do: query 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() @spec normalize_search_string(String.t()) :: String.t()
defp normalize_search_string(search_string) do defp normalize_search_string(search_string) do
search_string search_string