Merge branch 'resource-pagination' into 'master'

Add pagination to resources

Closes #727

See merge request framasoft/mobilizon!939
This commit is contained in:
Thomas Citharel 2021-06-14 14:12:21 +00:00
commit 6dc048f292
9 changed files with 192 additions and 39 deletions

View file

@ -162,7 +162,7 @@ a {
} }
.body { .body {
padding: 10px 8px 8px; padding: 8px;
flex: 1 1 auto; flex: 1 1 auto;
overflow: hidden; overflow: hidden;

View file

@ -20,21 +20,30 @@
</div> </div>
</div> </div>
<div class="body"> <div class="body">
<div class="title-wrapper">
<img <img
class="favicon" class="favicon"
v-if="resource.metadata && resource.metadata.faviconUrl" v-if="resource.metadata && resource.metadata.faviconUrl"
:src="resource.metadata.faviconUrl" :src="resource.metadata.faviconUrl"
/> />
<h3>{{ resource.title }}</h3> <h3>{{ resource.title }}</h3>
<span class="host" v-if="inline">{{ </div>
resource.updatedAt | formatDateTimeString <div class="metadata-wrapper">
}}</span> <span class="host" v-if="!inline || preview">{{ urlHostname }}</span>
<span class="host" v-else>{{ urlHostname }}</span> <span
class="published-at is-hidden-mobile"
v-if="resource.updatedAt || resource.publishedAt"
>{{
(resource.updatedAt || resource.publishedAt)
| formatDateTimeString
}}</span
>
</div>
</div> </div>
</a> </a>
<resource-dropdown <resource-dropdown
class="actions" class="actions"
v-if="!inline" v-if="!inline || !preview"
@delete="$emit('delete', resource.id)" @delete="$emit('delete', resource.id)"
@move="$emit('move', resource)" @move="$emit('move', resource)"
@rename="$emit('rename', resource)" @rename="$emit('rename', resource)"
@ -53,6 +62,7 @@ export default class ResourceItem extends Vue {
@Prop({ required: true, type: Object }) resource!: IResource; @Prop({ required: true, type: Object }) resource!: IResource;
@Prop({ required: false, default: false }) inline!: boolean; @Prop({ required: false, default: false }) inline!: boolean;
@Prop({ required: false, default: false }) preview!: boolean;
list = []; list = [];
@ -74,11 +84,12 @@ export default class ResourceItem extends Vue {
display: flex; display: flex;
flex: 1; flex: 1;
align-items: center; align-items: center;
width: 100%;
.actions { .actions {
flex: 0; flex: 0;
display: block; display: block;
margin: auto 1rem auto 2rem; margin: auto 1rem;
cursor: pointer; cursor: pointer;
} }
} }
@ -111,10 +122,15 @@ a {
} }
.body { .body {
padding: 10px 8px 8px; padding: 8px;
flex: 1 1 auto; flex: 1 1 auto;
overflow: hidden; overflow: hidden;
.title-wrapper {
display: flex;
max-width: calc(100vw - 122px);
}
img.favicon { img.favicon {
display: inline-block; display: inline-block;
width: 16px; width: 16px;
@ -134,9 +150,23 @@ a {
vertical-align: middle; vertical-align: middle;
} }
.host { .metadata-wrapper {
display: block; max-width: calc(100vw - 122px);
margin-top: 5px; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
span {
&:last-child::before {
content: "⋅";
padding: 0 5px;
}
&:first-child::before {
content: "";
padding: initial;
}
&.host,
&.published-at {
font-size: 13px; font-size: 13px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -144,4 +174,6 @@ a {
} }
} }
} }
}
}
</style> </style>

View file

@ -58,18 +58,34 @@
> >
{{ $t("No resources in this folder") }} {{ $t("No resources in this folder") }}
</p> </p>
<b-pagination
v-if="resource.children.total > RESOURCES_PER_PAGE"
:total="resource.children.total"
v-model="page"
size="is-small"
:per-page="RESOURCES_PER_PAGE"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
/>
</article> </article>
<div class="buttons">
<b-button type="is-text" @click="$emit('close-move-modal')">{{
$t("Cancel")
}}</b-button>
<b-button <b-button
type="is-primary" type="is-primary"
@click="updateResource" @click="updateResource"
:disabled="moveDisabled" :disabled="moveDisabled"
>{{ ><template v-if="resource.path === '/'">
$t("Move resource to {folder}", { folder: resource.title }) {{ $t("Move resource to the root folder") }}
}}</b-button </template>
<template v-else
>{{ $t("Move resource to {folder}", { folder: resource.title }) }}
</template></b-button
> >
<b-button type="is-text" @click="$emit('close-move-modal')">{{ </div>
$t("Cancel")
}}</b-button>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -86,6 +102,8 @@ import { IResource } from "../../types/resource";
return { return {
path: this.resource.path, path: this.resource.path,
username: this.username, username: this.username,
page: this.page,
limit: this.RESOURCES_PER_PAGE,
}; };
} }
return { path: "/", username: this.username }; return { path: "/", username: this.username };
@ -103,6 +121,10 @@ export default class ResourceSelector extends Vue {
resource: IResource | undefined = this.initialResource.parent; resource: IResource | undefined = this.initialResource.parent;
RESOURCES_PER_PAGE = 10;
page = 1;
goDown(element: IResource): void { goDown(element: IResource): void {
if (element.type === "folder" && element.id !== this.initialResource.id) { if (element.type === "folder" && element.id !== this.initialResource.id) {
this.resource = element; this.resource = element;
@ -150,4 +172,11 @@ export default class ResourceSelector extends Vue {
color: #fff; color: #fff;
} }
} }
.buttons {
justify-content: flex-end;
}
nav.pagination {
margin: 0.5rem;
}
</style> </style>

View file

@ -11,7 +11,12 @@ export const RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT = gql`
`; `;
export const GET_RESOURCE = gql` export const GET_RESOURCE = gql`
query GetResource($path: String!, $username: String!) { query GetResource(
$path: String!
$username: String!
$page: Int
$limit: Int
) {
resource(path: $path, username: $username) { resource(path: $path, username: $username) {
id id
title title
@ -38,7 +43,7 @@ export const GET_RESOURCE = gql`
name name
domain domain
} }
children { children(page: $page, limit: $limit) {
total total
elements { elements {
id id
@ -53,6 +58,9 @@ export const GET_RESOURCE = gql`
path path
type type
} }
publishedAt
updatedAt
insertedAt
metadata { metadata {
...ResourceMetadataBasicFields ...ResourceMetadataBasicFields
} }

View file

@ -275,7 +275,7 @@ export default class Participants extends Vue {
} }
set page(page: number) { set page(page: number) {
this.pushRouter(RouteName.RELAY_FOLLOWINGS, { this.pushRouter(RouteName.PARTICIPATIONS, {
page: page.toString(), page: page.toString(),
}); });
} }

View file

@ -145,6 +145,17 @@
<p>{{ $t("No resources in this folder") }}</p> <p>{{ $t("No resources in this folder") }}</p>
</div> </div>
</section> </section>
<b-pagination
v-if="resource.children.total > RESOURCES_PER_PAGE"
:total="resource.children.total"
v-model="page"
:per-page="RESOURCES_PER_PAGE"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
>
</b-pagination>
<b-modal :active.sync="renameModal" has-modal-card> <b-modal :active.sync="renameModal" has-modal-card>
<div class="modal-card"> <div class="modal-card">
<section class="modal-card-body"> <section class="modal-card-body">
@ -187,7 +198,11 @@
</section> </section>
</div> </div>
</b-modal> </b-modal>
<b-modal :active.sync="createLinkResourceModal" has-modal-card> <b-modal
:active.sync="createLinkResourceModal"
has-modal-card
class="link-resource-modal"
>
<div class="modal-card"> <div class="modal-card">
<section class="modal-card-body"> <section class="modal-card-body">
<b-message type="is-danger" v-if="modalError"> <b-message type="is-danger" v-if="modalError">
@ -204,7 +219,7 @@
</b-field> </b-field>
<div class="new-resource-preview" v-if="newResource.title"> <div class="new-resource-preview" v-if="newResource.title">
<resource-item :resource="newResource" /> <resource-item :resource="newResource" :preview="true" />
</div> </div>
<b-field :label="$t('Title')"> <b-field :label="$t('Title')">
@ -250,6 +265,8 @@ import { IConfig } from "../../types/config.model";
import ResourceMixin from "../../mixins/resource"; import ResourceMixin from "../../mixins/resource";
import ResourceSelector from "../../components/Resource/ResourceSelector.vue"; import ResourceSelector from "../../components/Resource/ResourceSelector.vue";
import { ApolloCache, FetchResult } from "@apollo/client/core"; import { ApolloCache, FetchResult } from "@apollo/client/core";
import VueRouter from "vue-router";
const { isNavigationFailure, NavigationFailureType } = VueRouter;
@Component({ @Component({
components: { FolderItem, ResourceItem, Draggable, ResourceSelector }, components: { FolderItem, ResourceItem, Draggable, ResourceSelector },
@ -265,6 +282,8 @@ import { ApolloCache, FetchResult } from "@apollo/client/core";
return { return {
path, path,
username: this.$route.params.preferredUsername, username: this.$route.params.preferredUsername,
page: this.page,
limit: this.RESOURCES_PER_PAGE,
}; };
}, },
error({ graphQLErrors }) { error({ graphQLErrors }) {
@ -303,6 +322,8 @@ export default class Resources extends Mixins(ResourceMixin) {
usernameWithDomain = usernameWithDomain; usernameWithDomain = usernameWithDomain;
RESOURCES_PER_PAGE = 10;
newResource: IResource = { newResource: IResource = {
title: "", title: "",
summary: "", summary: "",
@ -344,6 +365,16 @@ export default class Resources extends Mixins(ResourceMixin) {
mapServiceTypeToIcon = mapServiceTypeToIcon; mapServiceTypeToIcon = mapServiceTypeToIcon;
get page(): number {
return parseInt((this.$route.query.page as string) || "1", 10);
}
set page(page: number) {
this.pushRouter({
page: page.toString(),
});
}
get actualPath(): string { get actualPath(): string {
const path = Array.isArray(this.$route.params.path) const path = Array.isArray(this.$route.params.path)
? this.$route.params.path.join("/") ? this.$route.params.path.join("/")
@ -641,16 +672,51 @@ export default class Resources extends Mixins(ResourceMixin) {
} }
} }
@Watch("page")
loadMoreResources(): void {
this.$apollo.queries.resource.fetchMore({
// New variables
variables: {
page: this.page,
limit: this.RESOURCES_PER_PAGE,
},
});
}
handleErrors(errors: any[]): void { handleErrors(errors: any[]): void {
if (errors.some((error) => error.status_code === 404)) { if (errors.some((error) => error.status_code === 404)) {
this.$router.replace({ name: RouteName.PAGE_NOT_FOUND }); this.$router.replace({ name: RouteName.PAGE_NOT_FOUND });
} }
} }
async pushRouter(args: Record<string, string>): Promise<void> {
try {
const path = this.filteredPath.toString();
const routeName =
path === ""
? RouteName.RESOURCE_FOLDER_ROOT
: RouteName.RESOURCE_FOLDER;
await this.$router.push({
name: routeName,
params: { path },
query: { ...this.$route.query, ...args },
});
} catch (e) {
if (isNavigationFailure(e, NavigationFailureType.redirected)) {
throw Error(e.toString());
}
}
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.container.section { .container.section {
background: $white; background: $white;
& > nav.pagination {
margin-top: 1rem;
}
} }
nav.breadcrumb ul { nav.breadcrumb ul {
@ -675,6 +741,10 @@ nav.breadcrumb ul {
display: flex; display: flex;
align-items: center; align-items: center;
::v-deep .b-checkbox.checkbox {
margin-left: 10px;
}
.actions { .actions {
margin-right: 5px; margin-right: 5px;
@ -693,11 +763,16 @@ nav.breadcrumb ul {
border-radius: 4px; border-radius: 4px;
color: #444b5d; color: #444b5d;
margin-top: 14px; margin-top: 14px;
margin-bottom: 14px;
.resource-checkbox { .resource-checkbox {
align-self: center; align-self: center;
padding: 0 3px 0 10px; padding-left: 10px;
opacity: 0.3; opacity: 0.3;
::v-deep .b-checkbox.checkbox {
margin-right: 0.25rem;
}
} }
&:hover .resource-checkbox, &:hover .resource-checkbox,

View file

@ -50,7 +50,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Resource do
def find_resources_for_parent( def find_resources_for_parent(
%Resource{actor_id: group_id} = parent, %Resource{actor_id: group_id} = parent,
_args, %{page: page, limit: limit},
%{ %{
context: %{ context: %{
current_user: %User{} = user current_user: %User{} = user
@ -59,7 +59,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Resource do
) do ) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user), with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)}, {:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
%Page{} = page <- Resources.get_resources_for_folder(parent) do %Page{} = page <- Resources.get_resources_for_folder(parent, page, limit) do
{:ok, page} {:ok, page}
end end
end end

View file

@ -20,6 +20,7 @@ defmodule Mobilizon.GraphQL.Schema.ResourceType do
field(:actor, :actor, description: "The resource's owner") field(:actor, :actor, description: "The resource's owner")
field(:inserted_at, :naive_datetime, description: "The resource's creation date") field(:inserted_at, :naive_datetime, description: "The resource's creation date")
field(:updated_at, :naive_datetime, description: "The resource's last update date") field(:updated_at, :naive_datetime, description: "The resource's last update date")
field(:published_at, :naive_datetime, description: "The resource's publication date")
field(:type, :string, description: "The resource's type (if it's a folder)") field(:type, :string, description: "The resource's type (if it's a folder)")
field(:path, :string, description: "The resource's path") field(:path, :string, description: "The resource's path")
@ -27,6 +28,14 @@ defmodule Mobilizon.GraphQL.Schema.ResourceType do
field :children, :paginated_resource_list do field :children, :paginated_resource_list do
description("Children resources in folder") description("Children resources in folder")
arg(:page, :integer,
default_value: 1,
description: "The page in the paginated resource list"
)
arg(:limit, :integer, default_value: 10, description: "The limit of resources per page")
resolve(&Resource.find_resources_for_parent/3) resolve(&Resource.find_resources_for_parent/3)
end end
end end

View file

@ -47,7 +47,7 @@ defmodule Mobilizon.Resources do
) do ) do
Resource Resource
|> where([r], r.actor_id == ^group_id and is_nil(r.parent_id)) |> where([r], r.actor_id == ^group_id and is_nil(r.parent_id))
|> order_by(asc: :type) |> order_by(asc: :type, asc: :title)
|> preload([r], [:actor, :creator]) |> preload([r], [:actor, :creator])
|> Page.build_page(page, limit) |> Page.build_page(page, limit)
end end
@ -55,7 +55,7 @@ defmodule Mobilizon.Resources do
def get_resources_for_folder(%Resource{id: resource_id}, page, limit) do def get_resources_for_folder(%Resource{id: resource_id}, page, limit) do
Resource Resource
|> where([r], r.parent_id == ^resource_id) |> where([r], r.parent_id == ^resource_id)
|> order_by(asc: :type) |> order_by(asc: :type, asc: :title)
|> preload([r], [:actor, :creator]) |> preload([r], [:actor, :creator])
|> Page.build_page(page, limit) |> Page.build_page(page, limit)
end end