Merge branch 'fix-nav-search-field' into 'master'

Fix nav search field

Closes #450

See merge request framasoft/mobilizon!753
This commit is contained in:
Thomas Citharel 2020-12-09 20:08:39 +01:00
commit 5f3531cc18
11 changed files with 219 additions and 200 deletions

View File

@ -44,7 +44,7 @@ type RefreshedToken {
} }
"Represents an application" "Represents an application"
type Application { type Application implements Actor {
"Internal ID for this application" "Internal ID for this application"
id: ID id: ID
@ -336,7 +336,7 @@ type PaginatedPostList {
} }
"A comment" "A comment"
type Comment { type Comment implements ActionLogObject {
"Internal ID for this comment" "Internal ID for this comment"
id: ID id: ID
@ -893,7 +893,7 @@ enum EventCommentModeration {
} }
"Represents a person identity" "Represents a person identity"
type Person { type Person implements ActionLogObject & Actor {
"Internal ID for this person" "Internal ID for this person"
id: ID id: ID
@ -1949,7 +1949,7 @@ type RootQueryType {
"The limit of events per page" "The limit of events per page"
limit: Int limit: Int
): [Event] ): PaginatedEventList
"Get an event by uuid" "Get an event by uuid"
event("The event's UUID" uuid: UUID!): Event event("The event's UUID" uuid: UUID!): Event
@ -2219,7 +2219,7 @@ input EventOptionsInput {
} }
"A report object" "A report object"
type Report { type Report implements ActionLogObject {
"The internal ID of the report" "The internal ID of the report"
id: ID id: ID
@ -2285,7 +2285,7 @@ type PaginatedTodoListList {
} }
"An event" "An event"
type Event { type Event implements Interactable & ActionLogObject {
"Internal ID for this event" "Internal ID for this event"
id: ID id: ID
@ -2737,7 +2737,7 @@ type Geocoding {
} }
"A report note object" "A report note object"
type ReportNote { type ReportNote implements ActionLogObject {
"The internal ID of the report note" "The internal ID of the report note"
id: ID id: ID
@ -3052,7 +3052,7 @@ type Member {
} }
"A local user of Mobilizon" "A local user of Mobilizon"
type User { type User implements ActionLogObject {
"The user's ID" "The user's ID"
id: ID id: ID
@ -3157,7 +3157,7 @@ type User {
} }
"Represents a group of actors" "Represents a group of actors"
type Group { type Group implements Interactable & Actor {
"Internal ID for this group" "Internal ID for this group"
id: ID id: ID

View File

@ -196,38 +196,40 @@ export const FETCH_EVENT_BASIC = gql`
export const FETCH_EVENTS = gql` export const FETCH_EVENTS = gql`
query { query {
events { events {
id, total
uuid, elements {
url, id
local, uuid
title, url
description, local
beginsOn, title
endsOn, description
status, beginsOn
visibility, endsOn
status
visibility
picture { picture {
id id
url url
}, }
publishAt, publishAt
# online_address, # online_address,
# phone_address, # phone_address,
physicalAddress { physicalAddress {
id, id
description, description
locality locality
}, }
organizerActor { organizerActor {
id, id
avatar { avatar {
id id
url url
}, }
preferredUsername, preferredUsername
domain, domain
name, name
}, }
# attributedTo { # attributedTo {
# avatar { # avatar {
# id # id
@ -236,14 +238,12 @@ export const FETCH_EVENTS = gql`
# preferredUsername, # preferredUsername,
# name, # name,
# }, # },
category, category
participants {
${participantsQuery}
},
tags { tags {
slug, slug
title title
}, }
}
} }
} }
`; `;

View File

@ -220,6 +220,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator"; import { Component, Vue, Watch } from "vue-property-decorator";
import { ParticipantRole } from "@/types/enums"; import { ParticipantRole } from "@/types/enums";
import { Paginate } from "@/types/paginate";
import { IParticipant, Participant } from "../types/participant.model"; import { IParticipant, Participant } from "../types/participant.model";
import { FETCH_EVENTS } from "../graphql/event"; import { FETCH_EVENTS } from "../graphql/event";
import EventListCard from "../components/Event/EventListCard.vue"; import EventListCard from "../components/Event/EventListCard.vue";
@ -295,7 +296,7 @@ import Subtitle from "../components/Utils/Subtitle.vue";
}, },
}) })
export default class Home extends Vue { export default class Home extends Vue {
events: IEvent[] = []; events!: Paginate<IEvent>;
locations = []; locations = [];
@ -437,7 +438,7 @@ export default class Home extends Vue {
* Return all events from server excluding the ones shown as participating * Return all events from server excluding the ones shown as participating
*/ */
get filteredFeaturedEvents(): IEvent[] { get filteredFeaturedEvents(): IEvent[] {
return this.events.filter( return this.events.elements.filter(
({ id }) => ({ id }) =>
!this.currentUserParticipations !this.currentUserParticipations
.filter( .filter(

View File

@ -46,7 +46,7 @@
<option <option
v-for="(option, index) in options" v-for="(option, index) in options"
:key="index" :key="index"
:value="option" :value="index"
> >
{{ option.label }} {{ option.label }}
</option> </option>
@ -56,20 +56,23 @@
</form> </form>
</div> </div>
</section> </section>
<section class="events-featured" v-if="!tag && searchEvents.initial"> <section
class="events-featured"
v-if="!tag && !(search || location.geom || when !== 'any')"
>
<b-loading :active.sync="$apollo.loading"></b-loading> <b-loading :active.sync="$apollo.loading"></b-loading>
<h2 class="title">{{ $t("Featured events") }}</h2> <h2 class="title">{{ $t("Featured events") }}</h2>
<div v-if="events.length > 0" class="columns is-multiline"> <div v-if="events.elements.length > 0" class="columns is-multiline">
<div <div
class="column is-one-third-desktop" class="column is-one-third-desktop"
v-for="event in events" v-for="event in events.elements"
:key="event.uuid" :key="event.uuid"
> >
<EventCard :event="event" /> <EventCard :event="event" />
</div> </div>
</div> </div>
<b-message <b-message
v-else-if="events.length === 0 && $apollo.loading === false" v-else-if="events.elements.length === 0 && $apollo.loading === false"
type="is-danger" type="is-danger"
>{{ $t("No events found") }}</b-message >{{ $t("No events found") }}</b-message
> >
@ -109,15 +112,24 @@
<b-message v-else-if="$apollo.loading === false" type="is-danger">{{ <b-message v-else-if="$apollo.loading === false" type="is-danger">{{
$t("No events found") $t("No events found")
}}</b-message> }}</b-message>
<b-loading
v-else-if="$apollo.loading"
:is-full-page="false"
v-model="$apollo.loading"
:can-cancel="false"
/>
</b-tab-item> </b-tab-item>
<b-tab-item v-if="config && config.features.groups"> <b-tab-item v-if="!tag">
<template slot="header"> <template slot="header">
<b-icon icon="account-multiple"></b-icon> <b-icon icon="account-multiple"></b-icon>
<span> <span>
{{ $t("Groups") }} <b-tag rounded>{{ searchGroups.total }}</b-tag> {{ $t("Groups") }} <b-tag rounded>{{ searchGroups.total }}</b-tag>
</span> </span>
</template> </template>
<div v-if="searchGroups.total > 0"> <b-message v-if="config && !config.features.groups" type="is-danger">
{{ $t("Groups are not enabled on your server.") }}
</b-message>
<div v-else-if="searchGroups.total > 0">
<div class="columns is-multiline"> <div class="columns is-multiline">
<div <div
class="column is-one-third-desktop" class="column is-one-third-desktop"
@ -143,13 +155,19 @@
<b-message v-else-if="$apollo.loading === false" type="is-danger"> <b-message v-else-if="$apollo.loading === false" type="is-danger">
{{ $t("No groups found") }} {{ $t("No groups found") }}
</b-message> </b-message>
<b-loading
v-else-if="$apollo.loading"
:is-full-page="false"
v-model="$apollo.loading"
:can-cancel="false"
/>
</b-tab-item> </b-tab-item>
</b-tabs> </b-tabs>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import ngeohash from "ngeohash"; import ngeohash from "ngeohash";
import { import {
endOfToday, endOfToday,
@ -165,6 +183,7 @@ import {
eachWeekendOfInterval, eachWeekendOfInterval,
} from "date-fns"; } from "date-fns";
import { SearchTabs } from "@/types/enums"; import { SearchTabs } from "@/types/enums";
import { RawLocation } from "vue-router";
import EventCard from "../components/Event/EventCard.vue"; import EventCard from "../components/Event/EventCard.vue";
import { FETCH_EVENTS } from "../graphql/event"; import { FETCH_EVENTS } from "../graphql/event";
import { IEvent } from "../types/event.model"; import { IEvent } from "../types/event.model";
@ -183,11 +202,6 @@ interface ISearchTimeOption {
end?: Date | null; end?: Date | null;
} }
const tabsName: { events: number; groups: number } = {
events: SearchTabs.EVENTS,
groups: SearchTabs.GROUPS,
};
const EVENT_PAGE_LIMIT = 10; const EVENT_PAGE_LIMIT = 10;
const GROUP_PAGE_LIMIT = 10; const GROUP_PAGE_LIMIT = 10;
@ -218,7 +232,7 @@ const GROUP_PAGE_LIMIT = 10;
}, },
debounce: 300, debounce: 300,
skip() { skip() {
return !this.search && !this.tag && !this.geohash && this.end === null; return !this.tag && !this.geohash && this.end === null;
}, },
}, },
searchGroups: { searchGroups: {
@ -250,12 +264,14 @@ const GROUP_PAGE_LIMIT = 10;
export default class Search extends Vue { export default class Search extends Vue {
@Prop({ type: String, required: false }) tag!: string; @Prop({ type: String, required: false }) tag!: string;
events: IEvent[] = []; events: Paginate<IEvent> = {
total: 0,
searchEvents: Paginate<IEvent> & { initial: boolean } = { elements: [],
};
searchEvents: Paginate<IEvent> = {
total: 0, total: 0,
elements: [], elements: [],
initial: true,
}; };
searchGroups: Paginate<IGroup> = { total: 0, elements: [] }; searchGroups: Paginate<IGroup> = { total: 0, elements: [] };
@ -264,62 +280,51 @@ export default class Search extends Vue {
groupPage = 1; groupPage = 1;
search: string = (this.$route.query.term as string) || "";
activeTab: SearchTabs =
tabsName[this.$route.query.searchType as "events" | "groups"] || 0;
location: IAddress = new Address(); location: IAddress = new Address();
options: ISearchTimeOption[] = [ options: Record<string, ISearchTimeOption> = {
{ today: {
label: this.$t("Today") as string, label: this.$t("Today") as string,
start: new Date(), start: new Date(),
end: endOfToday(), end: endOfToday(),
}, },
{ tomorrow: {
label: this.$t("Tomorrow") as string, label: this.$t("Tomorrow") as string,
start: startOfDay(addDays(new Date(), 1)), start: startOfDay(addDays(new Date(), 1)),
end: endOfDay(addDays(new Date(), 1)), end: endOfDay(addDays(new Date(), 1)),
}, },
{ weekend: {
label: this.$t("This weekend") as string, label: this.$t("This weekend") as string,
start: this.weekend.start, start: this.weekend.start,
end: this.weekend.end, end: this.weekend.end,
}, },
{ week: {
label: this.$t("This week") as string, label: this.$t("This week") as string,
start: new Date(), start: new Date(),
end: endOfWeek(new Date(), { locale: this.$dateFnsLocale }), end: endOfWeek(new Date(), { locale: this.$dateFnsLocale }),
}, },
{ next_week: {
label: this.$t("Next week") as string, label: this.$t("Next week") as string,
start: startOfWeek(addWeeks(new Date(), 1), { start: startOfWeek(addWeeks(new Date(), 1), {
locale: this.$dateFnsLocale, locale: this.$dateFnsLocale,
}), }),
end: endOfWeek(addWeeks(new Date(), 1), { locale: this.$dateFnsLocale }), end: endOfWeek(addWeeks(new Date(), 1), { locale: this.$dateFnsLocale }),
}, },
{ month: {
label: this.$t("This month") as string, label: this.$t("This month") as string,
start: new Date(), start: new Date(),
end: endOfMonth(new Date()), end: endOfMonth(new Date()),
}, },
{ next_month: {
label: this.$t("Next month") as string, label: this.$t("Next month") as string,
start: startOfMonth(addMonths(new Date(), 1)), start: startOfMonth(addMonths(new Date(), 1)),
end: endOfMonth(addMonths(new Date(), 1)), end: endOfMonth(addMonths(new Date(), 1)),
}, },
{ any: {
label: this.$t("Any day") as string, label: this.$t("Any day") as string,
start: undefined, start: undefined,
end: undefined, end: undefined,
}, },
];
when: ISearchTimeOption = {
label: this.$t("Any day") as string,
start: undefined,
end: null,
}; };
EVENT_PAGE_LIMIT = EVENT_PAGE_LIMIT; EVENT_PAGE_LIMIT = EVENT_PAGE_LIMIT;
@ -335,26 +340,60 @@ export default class Search extends Vue {
radiusOptions: (number | null)[] = [1, 5, 10, 25, 50, 100, 150, null]; radiusOptions: (number | null)[] = [1, 5, 10, 25, 50, 100, 150, null];
radius = 50;
submit(): void { submit(): void {
this.$apollo.queries.searchEvents.refetch(); this.$apollo.queries.searchEvents.refetch();
} }
@Watch("search") get search(): string | undefined {
updateSearchTerm(): void { return this.$route.query.term as string;
this.$router.push({ }
set search(term: string | undefined) {
const route: RawLocation = {
name: RouteName.SEARCH, name: RouteName.SEARCH,
query: { ...this.$route.query, term: this.search }, };
if (term !== "") {
route.query = { ...this.$route.query, term };
}
this.$router.replace(route);
}
get activeTab(): SearchTabs {
return (
parseInt(this.$route.query.searchType as string, 10) || SearchTabs.EVENTS
);
}
set activeTab(value: SearchTabs) {
this.$router.replace({
name: RouteName.SEARCH,
query: { ...this.$route.query, searchType: value.toString() },
}); });
} }
@Watch("activeTab") get radius(): number | null {
updateActiveTab(): void { if (this.$route.query.radius === "any") {
const searchType = this.activeTab === tabsName.events ? "events" : "groups"; return null;
this.$router.push({ }
return parseInt(this.$route.query.radius as string, 10) || null;
}
set radius(value: number | null) {
const radius = value === null ? "any" : value.toString();
this.$router.replace({
name: RouteName.SEARCH, name: RouteName.SEARCH,
query: { ...this.$route.query, searchType }, query: { ...this.$route.query, radius },
});
}
get when(): string {
return (this.$route.query.when as string) || "any";
}
set when(value: string) {
this.$router.replace({
name: RouteName.SEARCH,
query: { ...this.$route.query, when: value },
}); });
} }
@ -378,11 +417,17 @@ export default class Search extends Vue {
} }
get start(): Date | undefined { get start(): Date | undefined {
return this.when.start; if (this.options[this.when]) {
return this.options[this.when].start;
}
return undefined;
} }
get end(): Date | undefined | null { get end(): Date | undefined | null {
return this.when.end; if (this.options[this.when]) {
return this.options[this.when].end;
}
return undefined;
} }
} }
</script> </script>

View File

@ -190,7 +190,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
when is_admin(role) do when is_admin(role) do
last_public_event_published = last_public_event_published =
case Events.list_events(1, 1, :inserted_at, :desc) do case Events.list_events(1, 1, :inserted_at, :desc) do
[event | _] -> event %Page{elements: [event | _]} -> event
_ -> nil _ -> nil
end end

View File

@ -161,7 +161,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
events = events =
if @number_of_related_events - length(events) > 0 do if @number_of_related_events - length(events) > 0 do
events events
|> Enum.concat(Events.list_events(1, @number_of_related_events, :begins_on, :asc, true)) |> Enum.concat(
Events.list_events(1, @number_of_related_events, :begins_on, :asc, true).elements
)
|> uniq_events() |> uniq_events()
else else
events events

View File

@ -299,7 +299,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
object :event_queries do object :event_queries do
@desc "Get all events" @desc "Get all events"
field :events, list_of(:event) do field :events, :paginated_event_list do
arg(:page, :integer, default_value: 1, description: "The page in the paginated event list") arg(:page, :integer, default_value: 1, description: "The page in the paginated event list")
arg(:limit, :integer, default_value: 10, description: "The limit of events per page") arg(:limit, :integer, default_value: 10, description: "The limit of events per page")
resolve(&Event.list_events/3) resolve(&Event.list_events/3)

View File

@ -357,7 +357,7 @@ defmodule Mobilizon.Events do
direction \\ :asc, direction \\ :asc,
is_future \\ true is_future \\ true
) do ) do
query = from(e in Event, distinct: true, preload: [:organizer_actor, :participants]) query = from(e in Event, preload: [:organizer_actor, :participants])
query query
|> sort(sort, direction) |> sort(sort, direction)
@ -365,8 +365,7 @@ defmodule Mobilizon.Events do
|> filter_public_visibility() |> filter_public_visibility()
|> filter_draft() |> filter_draft()
|> filter_local_or_from_followed_instances_events() |> filter_local_or_from_followed_instances_events()
|> Page.paginate(page, limit) |> Page.build_page(page, limit)
|> Repo.all()
end end
@spec stream_events_for_sitemap :: Enum.t() @spec stream_events_for_sitemap :: Enum.t()

View File

@ -19,12 +19,15 @@ defmodule Mobilizon.Storage.Page do
@doc """ @doc """
Returns a Page struct for a query. Returns a Page struct for a query.
`field` is use to define the field that will be used for the count aggregate, which should be the same as the field used for order_by
See https://stackoverflow.com/q/12693089/10204399
""" """
@spec build_page(Ecto.Query.t(), integer | nil, integer | nil) :: t @spec build_page(Ecto.Query.t(), integer | nil, integer | nil, atom()) :: t
def build_page(query, page, limit) do def build_page(query, page, limit, field \\ :id) do
[total, elements] = [total, elements] =
[ [
fn -> Repo.aggregate(query, :count, :id) end, fn -> Repo.aggregate(query, :count, field) end,
fn -> Repo.all(paginate(query, page, limit)) end fn -> Repo.all(paginate(query, page, limit)) end
] ]
|> Enum.map(&Task.async/1) |> Enum.map(&Task.async/1)

View File

@ -1144,120 +1144,89 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
] ]
end end
test "list_events/3 returns events", context do @fetch_events_query """
event = insert(:event) query Events($page: Int, $limit: Int) {
events(page: $page, limit: $limit) {
query = """ total
{ elements {
events { uuid
uuid, }
} }
} }
""" """
res = test "list_events/3 returns events", %{conn: conn} do
context.conn event = insert(:event)
|> get("/api", AbsintheHelpers.query_skeleton(query, "event"))
assert json_response(res, 200)["data"]["events"] |> Enum.map(& &1["uuid"]) == [event.uuid] res =
conn
|> AbsintheHelpers.graphql_query(query: @fetch_events_query)
assert res["data"]["events"]["elements"] |> Enum.map(& &1["uuid"]) == [
event.uuid
]
Enum.each(0..15, fn _ -> Enum.each(0..15, fn _ ->
insert(:event) insert(:event)
end) end)
query = """ res =
{ conn
events { |> AbsintheHelpers.graphql_query(query: @fetch_events_query)
uuid,
} assert res["data"]["events"]["total"] == 17
} assert res["data"]["events"]["elements"] |> length == 10
"""
res = res =
context.conn conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "event")) |> AbsintheHelpers.graphql_query(query: @fetch_events_query, variables: %{page: 2})
assert json_response(res, 200)["data"]["events"] |> length == 10 assert res["data"]["events"]["total"] == 17
assert res["data"]["events"]["elements"] |> length == 7
query = """
{
events(page: 2) {
uuid,
}
}
"""
res = res =
context.conn conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "event")) |> AbsintheHelpers.graphql_query(
query: @fetch_events_query,
variables: %{page: 2, limit: 15}
)
assert json_response(res, 200)["data"]["events"] |> length == 7 assert res["data"]["events"]["total"] == 17
assert res["data"]["events"]["elements"] |> length == 2
query = """
{
events(page: 2, limit: 15) {
uuid,
}
}
"""
res = res =
context.conn conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "event")) |> AbsintheHelpers.graphql_query(
query: @fetch_events_query,
variables: %{page: 3, limit: 15}
)
assert json_response(res, 200)["data"]["events"] |> length == 2 assert res["data"]["events"]["total"] == 17
assert res["data"]["events"]["elements"] |> length == 0
query = """
{
events(page: 3, limit: 15) {
uuid,
}
}
"""
res =
context.conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "event"))
assert json_response(res, 200)["data"]["events"] |> length == 0
end end
test "list_events/3 doesn't list private events", context do test "list_events/3 doesn't list private events", %{conn: conn} do
insert(:event, visibility: :private) insert(:event, visibility: :private)
insert(:event, visibility: :unlisted) insert(:event, visibility: :unlisted)
insert(:event, visibility: :restricted) insert(:event, visibility: :restricted)
query = """
{
events {
uuid,
}
}
"""
res = res =
context.conn conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "event")) |> AbsintheHelpers.graphql_query(query: @fetch_events_query)
assert json_response(res, 200)["data"]["events"] |> Enum.map(& &1["uuid"]) == [] assert res["data"]["events"]["total"] == 0
assert res["data"]["events"]["elements"] |> Enum.map(& &1["uuid"]) == []
end end
test "list_events/3 doesn't list draft events", context do test "list_events/3 doesn't list draft events", %{conn: conn} do
insert(:event, visibility: :public, draft: true) insert(:event, visibility: :public, draft: true)
query = """
{
events {
uuid,
}
}
"""
res = res =
context.conn conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "event")) |> AbsintheHelpers.graphql_query(query: @fetch_events_query)
assert json_response(res, 200)["data"]["events"] |> Enum.map(& &1["uuid"]) == [] assert res["data"]["events"]["total"] == 0
assert res["data"]["events"]["elements"] |> Enum.map(& &1["uuid"]) == []
end end
test "find_event/3 returns an unlisted event", context do test "find_event/3 returns an unlisted event", context do

View File

@ -29,12 +29,12 @@ defmodule Mobilizon.EventsTest do
end end
test "list_events/0 returns all events", %{event: event} do test "list_events/0 returns all events", %{event: event} do
assert event.title == hd(Events.list_events()).title assert event.title == hd(Events.list_events().elements).title
end end
test "list_events/5 returns events from other instances if we follow them", test "list_events/5 returns events from other instances if we follow them",
%{event: _event} do %{event: _event} do
events = Events.list_events() events = Events.list_events().elements
assert length(events) == 1 assert length(events) == 1
%Actor{id: remote_instance_actor_id} = remote_instance_actor = insert(:instance_actor) %Actor{id: remote_instance_actor_id} = remote_instance_actor = insert(:instance_actor)
@ -46,7 +46,7 @@ defmodule Mobilizon.EventsTest do
insert(:follower, target_actor: remote_instance_actor, actor: own_instance_actor) insert(:follower, target_actor: remote_instance_actor, actor: own_instance_actor)
events = Events.list_events() events = Events.list_events().elements
assert length(events) == 2 assert length(events) == 2
assert events |> Enum.any?(fn event -> event.title == "My Remote event" end) assert events |> Enum.any?(fn event -> event.title == "My Remote event" end)
end end
@ -58,7 +58,7 @@ defmodule Mobilizon.EventsTest do
%Event{url: remote_event_url} = insert(:event, local: false, title: "My Remote event") %Event{url: remote_event_url} = insert(:event, local: false, title: "My Remote event")
Mobilizon.Share.create(remote_event_url, remote_instance_actor_id, remote_actor_id) Mobilizon.Share.create(remote_event_url, remote_instance_actor_id, remote_actor_id)
events = Events.list_events() events = Events.list_events().elements
assert length(events) == 1 assert length(events) == 1
assert events |> Enum.all?(fn event -> event.title != "My Remote event" end) assert events |> Enum.all?(fn event -> event.title != "My Remote event" end)
end end