diff --git a/js/schema.graphql b/js/schema.graphql index b6010ab90..c9e3930dd 100644 --- a/js/schema.graphql +++ b/js/schema.graphql @@ -44,7 +44,7 @@ type RefreshedToken { } "Represents an application" -type Application { +type Application implements Actor { "Internal ID for this application" id: ID @@ -336,7 +336,7 @@ type PaginatedPostList { } "A comment" -type Comment { +type Comment implements ActionLogObject { "Internal ID for this comment" id: ID @@ -893,7 +893,7 @@ enum EventCommentModeration { } "Represents a person identity" -type Person { +type Person implements ActionLogObject & Actor { "Internal ID for this person" id: ID @@ -1949,7 +1949,7 @@ type RootQueryType { "The limit of events per page" limit: Int - ): [Event] + ): PaginatedEventList "Get an event by uuid" event("The event's UUID" uuid: UUID!): Event @@ -2219,7 +2219,7 @@ input EventOptionsInput { } "A report object" -type Report { +type Report implements ActionLogObject { "The internal ID of the report" id: ID @@ -2285,7 +2285,7 @@ type PaginatedTodoListList { } "An event" -type Event { +type Event implements Interactable & ActionLogObject { "Internal ID for this event" id: ID @@ -2737,7 +2737,7 @@ type Geocoding { } "A report note object" -type ReportNote { +type ReportNote implements ActionLogObject { "The internal ID of the report note" id: ID @@ -3052,7 +3052,7 @@ type Member { } "A local user of Mobilizon" -type User { +type User implements ActionLogObject { "The user's ID" id: ID @@ -3157,7 +3157,7 @@ type User { } "Represents a group of actors" -type Group { +type Group implements Interactable & Actor { "Internal ID for this group" id: ID diff --git a/js/src/graphql/event.ts b/js/src/graphql/event.ts index 4b0085837..357e28ce9 100644 --- a/js/src/graphql/event.ts +++ b/js/src/graphql/event.ts @@ -196,54 +196,54 @@ export const FETCH_EVENT_BASIC = gql` export const FETCH_EVENTS = gql` query { events { - id, - uuid, - url, - local, - title, - description, - beginsOn, - endsOn, - status, - visibility, - picture { + total + elements { id + uuid url - }, - publishAt, - # online_address, - # phone_address, - physicalAddress { - id, - description, - locality - }, - organizerActor { - id, - avatar { + local + title + description + beginsOn + endsOn + status + visibility + picture { id url - }, - preferredUsername, - domain, - name, - }, -# attributedTo { -# avatar { -# id -# url -# }, -# preferredUsername, -# name, -# }, - category, - participants { - ${participantsQuery} - }, - tags { - slug, - title - }, + } + publishAt + # online_address, + # phone_address, + physicalAddress { + id + description + locality + } + organizerActor { + id + avatar { + id + url + } + preferredUsername + domain + name + } + # attributedTo { + # avatar { + # id + # url + # }, + # preferredUsername, + # name, + # }, + category + tags { + slug + title + } + } } } `; diff --git a/js/src/views/Home.vue b/js/src/views/Home.vue index 4e4c86508..41c975c25 100644 --- a/js/src/views/Home.vue +++ b/js/src/views/Home.vue @@ -220,6 +220,7 @@ <script lang="ts"> import { Component, Vue, Watch } from "vue-property-decorator"; import { ParticipantRole } from "@/types/enums"; +import { Paginate } from "@/types/paginate"; import { IParticipant, Participant } from "../types/participant.model"; import { FETCH_EVENTS } from "../graphql/event"; import EventListCard from "../components/Event/EventListCard.vue"; @@ -295,7 +296,7 @@ import Subtitle from "../components/Utils/Subtitle.vue"; }, }) export default class Home extends Vue { - events: IEvent[] = []; + events!: Paginate<IEvent>; locations = []; @@ -437,7 +438,7 @@ export default class Home extends Vue { * Return all events from server excluding the ones shown as participating */ get filteredFeaturedEvents(): IEvent[] { - return this.events.filter( + return this.events.elements.filter( ({ id }) => !this.currentUserParticipations .filter( diff --git a/js/src/views/Search.vue b/js/src/views/Search.vue index 2c2fcf6fc..e02794676 100644 --- a/js/src/views/Search.vue +++ b/js/src/views/Search.vue @@ -46,7 +46,7 @@ <option v-for="(option, index) in options" :key="index" - :value="option" + :value="index" > {{ option.label }} </option> @@ -56,20 +56,23 @@ </form> </div> </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> <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 class="column is-one-third-desktop" - v-for="event in events" + v-for="event in events.elements" :key="event.uuid" > <EventCard :event="event" /> </div> </div> <b-message - v-else-if="events.length === 0 && $apollo.loading === false" + v-else-if="events.elements.length === 0 && $apollo.loading === false" type="is-danger" >{{ $t("No events found") }}</b-message > @@ -109,15 +112,24 @@ <b-message v-else-if="$apollo.loading === false" type="is-danger">{{ $t("No events found") }}</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 v-if="config && config.features.groups"> + <b-tab-item v-if="!tag"> <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"> + <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="column is-one-third-desktop" @@ -143,13 +155,19 @@ <b-message v-else-if="$apollo.loading === false" type="is-danger"> {{ $t("No groups found") }} </b-message> + <b-loading + v-else-if="$apollo.loading" + :is-full-page="false" + v-model="$apollo.loading" + :can-cancel="false" + /> </b-tab-item> </b-tabs> </div> </template> <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 { endOfToday, @@ -165,6 +183,7 @@ 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"; @@ -183,11 +202,6 @@ interface ISearchTimeOption { end?: Date | null; } -const tabsName: { events: number; groups: number } = { - events: SearchTabs.EVENTS, - groups: SearchTabs.GROUPS, -}; - const EVENT_PAGE_LIMIT = 10; const GROUP_PAGE_LIMIT = 10; @@ -218,7 +232,7 @@ const GROUP_PAGE_LIMIT = 10; }, debounce: 300, skip() { - return !this.search && !this.tag && !this.geohash && this.end === null; + return !this.tag && !this.geohash && this.end === null; }, }, searchGroups: { @@ -250,12 +264,14 @@ const GROUP_PAGE_LIMIT = 10; export default class Search extends Vue { @Prop({ type: String, required: false }) tag!: string; - events: IEvent[] = []; - - searchEvents: Paginate<IEvent> & { initial: boolean } = { + events: Paginate<IEvent> = { + total: 0, + elements: [], + }; + + searchEvents: Paginate<IEvent> = { total: 0, elements: [], - initial: true, }; searchGroups: Paginate<IGroup> = { total: 0, elements: [] }; @@ -264,62 +280,51 @@ export default class Search extends Vue { 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(); - options: ISearchTimeOption[] = [ - { + options: Record<string, ISearchTimeOption> = { + today: { label: this.$t("Today") as string, start: new Date(), end: endOfToday(), }, - { + tomorrow: { label: this.$t("Tomorrow") as string, start: startOfDay(addDays(new Date(), 1)), end: endOfDay(addDays(new Date(), 1)), }, - { + weekend: { label: this.$t("This weekend") as string, start: this.weekend.start, end: this.weekend.end, }, - { + week: { label: this.$t("This week") as string, start: new Date(), end: endOfWeek(new Date(), { locale: this.$dateFnsLocale }), }, - { + next_week: { 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 }), }, - { + month: { label: this.$t("This month") as string, start: new Date(), end: endOfMonth(new Date()), }, - { + next_month: { label: this.$t("Next month") as string, start: startOfMonth(addMonths(new Date(), 1)), end: endOfMonth(addMonths(new Date(), 1)), }, - { + any: { label: this.$t("Any day") as string, start: undefined, end: undefined, }, - ]; - - when: ISearchTimeOption = { - label: this.$t("Any day") as string, - start: undefined, - end: null, }; 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]; - radius = 50; - submit(): void { this.$apollo.queries.searchEvents.refetch(); } - @Watch("search") - updateSearchTerm(): void { - this.$router.push({ + get search(): string | undefined { + return this.$route.query.term as string; + } + + set search(term: string | undefined) { + const route: RawLocation = { 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") - updateActiveTab(): void { - const searchType = this.activeTab === tabsName.events ? "events" : "groups"; - this.$router.push({ + get radius(): number | null { + if (this.$route.query.radius === "any") { + return null; + } + 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, - 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 { - return this.when.start; + if (this.options[this.when]) { + return this.options[this.when].start; + } + return undefined; } get end(): Date | undefined | null { - return this.when.end; + if (this.options[this.when]) { + return this.options[this.when].end; + } + return undefined; } } </script> diff --git a/lib/graphql/resolvers/admin.ex b/lib/graphql/resolvers/admin.ex index 6aa412882..21698b06c 100644 --- a/lib/graphql/resolvers/admin.ex +++ b/lib/graphql/resolvers/admin.ex @@ -190,7 +190,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do when is_admin(role) do last_public_event_published = case Events.list_events(1, 1, :inserted_at, :desc) do - [event | _] -> event + %Page{elements: [event | _]} -> event _ -> nil end diff --git a/lib/graphql/resolvers/event.ex b/lib/graphql/resolvers/event.ex index 032a9ee20..ef142157e 100644 --- a/lib/graphql/resolvers/event.ex +++ b/lib/graphql/resolvers/event.ex @@ -161,7 +161,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do events = if @number_of_related_events - length(events) > 0 do 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() else events diff --git a/lib/graphql/schema/event.ex b/lib/graphql/schema/event.ex index e3cd9f31a..0b2ae40f3 100644 --- a/lib/graphql/schema/event.ex +++ b/lib/graphql/schema/event.ex @@ -299,7 +299,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do object :event_queries do @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(:limit, :integer, default_value: 10, description: "The limit of events per page") resolve(&Event.list_events/3) diff --git a/lib/mobilizon/events/events.ex b/lib/mobilizon/events/events.ex index 41ea85b32..89fed4cf4 100644 --- a/lib/mobilizon/events/events.ex +++ b/lib/mobilizon/events/events.ex @@ -357,7 +357,7 @@ defmodule Mobilizon.Events do direction \\ :asc, is_future \\ true ) do - query = from(e in Event, distinct: true, preload: [:organizer_actor, :participants]) + query = from(e in Event, preload: [:organizer_actor, :participants]) query |> sort(sort, direction) @@ -365,8 +365,7 @@ defmodule Mobilizon.Events do |> filter_public_visibility() |> filter_draft() |> filter_local_or_from_followed_instances_events() - |> Page.paginate(page, limit) - |> Repo.all() + |> Page.build_page(page, limit) end @spec stream_events_for_sitemap :: Enum.t() diff --git a/lib/mobilizon/storage/page.ex b/lib/mobilizon/storage/page.ex index af20d4a7b..68fc8aab3 100644 --- a/lib/mobilizon/storage/page.ex +++ b/lib/mobilizon/storage/page.ex @@ -19,12 +19,15 @@ defmodule Mobilizon.Storage.Page do @doc """ 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 - def build_page(query, page, limit) do + @spec build_page(Ecto.Query.t(), integer | nil, integer | nil, atom()) :: t + def build_page(query, page, limit, field \\ :id) do [total, elements] = [ - fn -> Repo.aggregate(query, :count, :id) end, + fn -> Repo.aggregate(query, :count, field) end, fn -> Repo.all(paginate(query, page, limit)) end ] |> Enum.map(&Task.async/1) diff --git a/test/graphql/resolvers/event_test.exs b/test/graphql/resolvers/event_test.exs index e974ed068..a83559e06 100644 --- a/test/graphql/resolvers/event_test.exs +++ b/test/graphql/resolvers/event_test.exs @@ -1144,120 +1144,89 @@ defmodule Mobilizon.Web.Resolvers.EventTest do ] end - test "list_events/3 returns events", context do - event = insert(:event) - - query = """ - { - events { - uuid, + @fetch_events_query """ + query Events($page: Int, $limit: Int) { + events(page: $page, limit: $limit) { + total + elements { + uuid } } - """ + } + """ + + test "list_events/3 returns events", %{conn: conn} do + event = insert(:event) res = - context.conn - |> get("/api", AbsintheHelpers.query_skeleton(query, "event")) + conn + |> AbsintheHelpers.graphql_query(query: @fetch_events_query) - assert json_response(res, 200)["data"]["events"] |> Enum.map(& &1["uuid"]) == [event.uuid] + assert res["data"]["events"]["elements"] |> Enum.map(& &1["uuid"]) == [ + event.uuid + ] Enum.each(0..15, fn _ -> insert(:event) end) - query = """ - { - events { - uuid, - } - } - """ + res = + conn + |> AbsintheHelpers.graphql_query(query: @fetch_events_query) + + assert res["data"]["events"]["total"] == 17 + assert res["data"]["events"]["elements"] |> length == 10 res = - context.conn - |> get("/api", AbsintheHelpers.query_skeleton(query, "event")) + conn + |> AbsintheHelpers.graphql_query(query: @fetch_events_query, variables: %{page: 2}) - assert json_response(res, 200)["data"]["events"] |> length == 10 - - query = """ - { - events(page: 2) { - uuid, - } - } - """ + assert res["data"]["events"]["total"] == 17 + assert res["data"]["events"]["elements"] |> length == 7 res = - context.conn - |> get("/api", AbsintheHelpers.query_skeleton(query, "event")) + conn + |> AbsintheHelpers.graphql_query( + query: @fetch_events_query, + variables: %{page: 2, limit: 15} + ) - assert json_response(res, 200)["data"]["events"] |> length == 7 - - query = """ - { - events(page: 2, limit: 15) { - uuid, - } - } - """ + assert res["data"]["events"]["total"] == 17 + assert res["data"]["events"]["elements"] |> length == 2 res = - context.conn - |> get("/api", AbsintheHelpers.query_skeleton(query, "event")) + conn + |> AbsintheHelpers.graphql_query( + query: @fetch_events_query, + variables: %{page: 3, limit: 15} + ) - assert json_response(res, 200)["data"]["events"] |> length == 2 - - 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 + assert res["data"]["events"]["total"] == 17 + assert res["data"]["events"]["elements"] |> length == 0 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: :unlisted) insert(:event, visibility: :restricted) - query = """ - { - events { - uuid, - } - } - """ - res = - context.conn - |> get("/api", AbsintheHelpers.query_skeleton(query, "event")) + conn + |> 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 - 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) - query = """ - { - events { - uuid, - } - } - """ - res = - context.conn - |> get("/api", AbsintheHelpers.query_skeleton(query, "event")) + conn + |> 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 test "find_event/3 returns an unlisted event", context do diff --git a/test/mobilizon/events/events_test.exs b/test/mobilizon/events/events_test.exs index a033b8a2c..fbda5b00a 100644 --- a/test/mobilizon/events/events_test.exs +++ b/test/mobilizon/events/events_test.exs @@ -29,12 +29,12 @@ defmodule Mobilizon.EventsTest do end 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 test "list_events/5 returns events from other instances if we follow them", %{event: _event} do - events = Events.list_events() + events = Events.list_events().elements assert length(events) == 1 %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) - events = Events.list_events() + events = Events.list_events().elements assert length(events) == 2 assert events |> Enum.any?(fn event -> event.title == "My Remote event" end) end @@ -58,7 +58,7 @@ defmodule Mobilizon.EventsTest do %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) - events = Events.list_events() + events = Events.list_events().elements assert length(events) == 1 assert events |> Enum.all?(fn event -> event.title != "My Remote event" end) end