Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

1587 implement pagination in user admin page #1588

Open
wants to merge 33 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5f3c0e4
adds all, very inspired by shadcn
Snorre98 Nov 1, 2024
4ac2ae6
removes unused code
Snorre98 Nov 1, 2024
f484882
adds Pagination to outer barrel file
Snorre98 Nov 1, 2024
cd8b516
removed more unused code and comments
Snorre98 Nov 1, 2024
cb2b4d2
improved / simplified story
Snorre98 Nov 1, 2024
5358ef3
adds supress unique key
Snorre98 Nov 1, 2024
c58468e
refactors styling
Snorre98 Nov 1, 2024
4c40b86
setup django rest framework pagination
Snorre98 Nov 1, 2024
3f8a42a
adds custom pagination classes and implements it in user view
Snorre98 Nov 2, 2024
b3f307a
implements pagination in user api call
Snorre98 Nov 2, 2024
3ac1a46
implements pagination component in user admin page
Snorre98 Nov 2, 2024
853ecfa
adds todo for cursor pagination
Snorre98 Nov 2, 2024
30219a3
renamed pagination type
Snorre98 Nov 2, 2024
433a372
changed number of items fetched
Snorre98 Nov 2, 2024
7e4daa4
adds cursor pagination interface
Snorre98 Nov 2, 2024
b780beb
setup cursor pagination
Snorre98 Nov 2, 2024
4c47b23
removes commented code
Snorre98 Nov 2, 2024
b808a9f
uses page size as a constant
Snorre98 Nov 2, 2024
7f9008e
adds margin to component
Snorre98 Nov 2, 2024
25718c3
Merge branch '1585-pagination-component' into 1587-implement-paginati…
Snorre98 Nov 2, 2024
c98a244
only renders pagination component if total items is larger than page …
Snorre98 Nov 2, 2024
ff1da3b
removes the use of pagination globaly, now it must be specified in th…
Snorre98 Nov 2, 2024
b7589ab
adds hook for RC using pagination
Snorre98 Nov 2, 2024
36e5b4e
implemetns usePaginatedQuery
Snorre98 Nov 2, 2024
cfa7a48
remove component before merge conflict
Snorre98 Nov 7, 2024
89e09bd
remove before merge conflict
Snorre98 Nov 7, 2024
f4df75f
Merge branch 'master' into 1587-implement-pagination-in-user-admin-page
Snorre98 Nov 7, 2024
411fb35
reafactor pagination to make http response custom
Snorre98 Nov 7, 2024
557bdbf
removes page size
Snorre98 Nov 7, 2024
e0ea641
fixed import
Snorre98 Nov 7, 2024
b98d1da
adds separart call for non-pagination
Snorre98 Nov 7, 2024
ec13d4c
trying to make a single call for both pagination and non-pagination
Snorre98 Nov 15, 2024
3ed2914
uses initial page correctly
Snorre98 Dec 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions backend/samfundet/pagination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from __future__ import annotations

from typing import Any

from rest_framework.response import Response
from rest_framework.pagination import CursorPagination, PageNumberPagination, LimitOffsetPagination


# 1. Page Number Pagination
class CustomPageNumberPagination(PageNumberPagination):
page_size = 2
page_size_query_param = 'page_size'
max_page_size = 50

def get_paginated_response(self, data: Any) -> Response:
return Response(
{
'page_size': self.get_page_size(self.request),
'count': self.page.paginator.count,
'next': self.get_next_link(),
'previous': self.get_previous_link(),
'current_page': self.page.number,
'total_pages': self.page.paginator.num_pages,
'results': data,
}
)

# URLs will look like: /api/items/?page=2


# 2. Limit-Offset Pagination
class CustomLimitOffsetPagination(LimitOffsetPagination):
default_limit = 10
max_limit = 100
# URLs will look like: /api/items/?limit=10&offset=20


# # 3. Cursor Pagination (good for infinite scroll)
class CustomCursorPagination(CursorPagination):
page_size = 10
ordering = '-created_at' # Must specify ordering field
# URLs will use an encoded cursor: /api/items/?cursor=cD0yMDIy


class UserCursorPagination(CursorPagination):
page_size = 10
ordering = '-date_joined' # Default ordering by newest first
cursor_query_param = 'cursor'
ordering_fields = ('date_joined', 'id')
9 changes: 8 additions & 1 deletion backend/samfundet/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
REQUESTED_IMPERSONATE_USER,
)

from samfundet.pagination import CustomPageNumberPagination

from .utils import event_query, generate_timeslots, get_occupied_timeslots_from_request
from .homepage import homepage
from .models.role import Role, UserOrgRole, UserGangRole, UserGangSectionRole
Expand Down Expand Up @@ -490,7 +492,12 @@ def get(self, request: Request) -> Response:
class AllUsersView(ListAPIView):
permission_classes = (DjangoModelPermissionsOrAnonReadOnly,)
serializer_class = UserSerializer
queryset = User.objects.all()
pagination_class = CustomPageNumberPagination

def get_queryset(self) -> QuerySet[User]:
queryset = User.objects.all()

return queryset.order_by('username')


class ImpersonateView(APIView):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.container {
display: flex;
flex-direction: column;
gap: 1rem;
}
104 changes: 54 additions & 50 deletions frontend/src/PagesAdmin/UsersAdminPage/UsersAdminPage.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,32 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { PagedPagination, SamfundetLogoSpinner } from '~/Components';
import { formatDate } from '~/Components/OccupiedForm/utils';
import { Table } from '~/Components/Table';
import { AdminPageLayout } from '~/PagesAdmin/AdminPageLayout/AdminPageLayout';
import { getUsers } from '~/api';
import type { UserDto } from '~/dto';
import { useTitle } from '~/hooks';
import { usePaginatedQuery, useTitle } from '~/hooks';
import { KEY } from '~/i18n/constants';
import { getFullName } from '~/utils';
import { ImpersonateButton } from './components';

export function UsersAdminPage() {
const { t } = useTranslation();

const [users, setUsers] = useState<UserDto[]>();
const [loading, setLoading] = useState(true);
const title = t(KEY.common_users);
useTitle(title);

useEffect(() => {
setLoading(true);
getUsers()
.then(setUsers)
.catch((err) => {
toast.error(t(KEY.common_something_went_wrong));
console.error(err);
})
.finally(() => setLoading(false));
}, [t]);
const {
data: users,
totalItems,
currentPage,
totalPages,
pageSize,
setCurrentPage,
isLoading,
} = usePaginatedQuery<UserDto>({
queryKey: ['admin-users'],
queryFn: (page: number) => getUsers(page),
});

const columns = [
{ content: t(KEY.common_username), sortable: true },
Expand All @@ -39,42 +37,48 @@ export function UsersAdminPage() {
{ content: '' },
];

const data = useMemo(() => {
if (!users) return [];
return users.map((u) => {
return {
cells: [
{
content: u.username,
value: u.username,
},
{
content: getFullName(u),
value: getFullName(u),
},
{
content: u.email,
value: u.email,
},
{
content: u.is_active ? t(KEY.common_yes) : '',
value: u.is_active,
},
{
content: u.last_login ? formatDate(new Date(u.last_login)) : '',
value: u.last_login || undefined,
},
{
content: <ImpersonateButton userId={u.id} />,
},
],
};
});
}, [t, users]);
const tableData = users.map((u) => {
return {
cells: [
{
content: u.username,
value: u.username,
},
{
content: getFullName(u),
value: getFullName(u),
},
{
content: u.email,
value: u.email,
},
{
content: u.is_active ? t(KEY.common_yes) : '',
value: u.is_active,
},
{
content: u.last_login ? formatDate(new Date(u.last_login)) : '',
value: u.last_login || undefined,
},
{
content: <ImpersonateButton userId={u.id} />,
},
],
};
});

return (
<AdminPageLayout title={title} loading={loading}>
<Table data={data} columns={columns} />
<AdminPageLayout title={title}>
{totalPages > 1 && (
<PagedPagination
currentPage={currentPage}
totalItems={totalItems}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
)}

{isLoading ? <SamfundetLogoSpinner position="center" /> : <Table data={tableData} columns={columns} />}
</AdminPageLayout>
);
}
20 changes: 17 additions & 3 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import type {
import { reverse } from '~/named-urls';
import { ROUTES } from '~/routes';
import { BACKEND_DOMAIN } from './constants';
import type { PageNumberPaginationType } from './types';

export async function getCsrfToken(): Promise<string> {
const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__csrf;
Expand Down Expand Up @@ -105,9 +106,22 @@ export async function impersonateUser(userId: number): Promise<boolean> {
return response.status === 200;
}

export async function getUsers(): Promise<UserDto[]> {
const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__users;
const response = await axios.get<UserDto[]>(url, { withCredentials: true });
// export async function getUsers(page?: number): Promise<UserDto[] | PageNumberPaginationType<UserDto>> {
// const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__users;
// if (page) {
// const paginatedUrl = `${url}?page=${page}`;
// const response = await axios.get<PageNumberPaginationType<UserDto>>(paginatedUrl, { withCredentials: true });
// return response.data;
// }

// const response = await axios.get<UserDto[]>(url, { withCredentials: true });
// return response.data;
// }

export async function getUsers(page?: number): Promise<PageNumberPaginationType<UserDto>> {
const url = `${BACKEND_DOMAIN + ROUTES.backend.samfundet__users}?page=${page}`;
const response = await axios.get<PageNumberPaginationType<UserDto>>(url, { withCredentials: true });
console.log(response.data);
return response.data;
}

Expand Down
3 changes: 3 additions & 0 deletions frontend/src/constants/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,6 @@ export const USERNAME_LENGTH_MIN = 2;
export const USERNAME_LENGTH_MAX = 32;
export const PASSWORD_LENGTH_MIN = 8;
export const PASSWORD_LENGTH_MAX = 2048;

// see pagination.py CustomPageNumberPagination
export const PAGE_SIZE = 25;
54 changes: 52 additions & 2 deletions frontend/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { type MutableRefObject, type RefObject, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { getTextItem, putUserPreference } from '~/api';
import type { Key, SetState } from '~/types';
import type { Key, PageNumberPaginationType, SetState } from '~/types';
import { createDot, hasPerm, isTruthy, updateBodyThemeClass } from '~/utils';
import type { LinkTarget } from './Components/Link/Link';
import { BACKEND_DOMAIN, THEME, THEME_KEY, type ThemeValue, desktopBpLower, mobileBpUpper } from './constants';
import {
BACKEND_DOMAIN,
PAGE_SIZE,
THEME,
THEME_KEY,
type ThemeValue,
desktopBpLower,
mobileBpUpper,
} from './constants';
import type { TextItemValue } from './constants/TextItems';
import { useAuthContext } from './context/AuthContext';
import { useGlobalContext } from './context/GlobalContextProvider';
Expand Down Expand Up @@ -503,3 +512,44 @@ export function useParentElementWidth(childRef: RefObject<HTMLElement>) {

return parentWidth;
}

interface UsePaginatedQueryOptions<T> {
queryKey: string[];
queryFn: (page: number) => Promise<PageNumberPaginationType<T>>;
pageSize?: number;
}

interface UsePaginatedQueryResult<T> {
data: T[];
totalItems: number;
currentPage: number;
totalPages: number;
pageSize: number;
setCurrentPage: (page: number) => void;
isLoading: boolean;
error: Error | null;
}

export function usePaginatedQuery<T>({
queryKey,
queryFn,
//initialPage = 1,
}: UsePaginatedQueryOptions<T>): UsePaginatedQueryResult<T> {
const [currentPage, setCurrentPage] = useState(1);

const { data, isLoading, error } = useQuery({
queryKey: [...queryKey, currentPage],
queryFn: () => queryFn(currentPage),
});

return {
data: data?.results ?? [],
totalItems: data?.count ?? 0,
currentPage: data?.current_page ?? currentPage,
totalPages: data?.total_pages ?? 1,
pageSize: data?.page_size ?? PAGE_SIZE,
setCurrentPage,
isLoading,
error,
};
}
18 changes: 18 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,21 @@ export const RecruitmentPriorityChoicesMapping: { [key: number]: string } = {
2: RecruitmentPriorityChoices.WANTED,
3: RecruitmentPriorityChoices.NOT_WANTED,
};

/* For DRF pagination, see pagination.py */
export interface PageNumberPaginationType<T> {
page_size: number;
count: number;
next: string | null;
previous: string | null;
current_page: number;
total_pages: number;
results: T[];
}

/* For DRF pagination, see pagination.py */
export interface CursorPaginatedResponse<T> {
next: string | null; // URL or cursor for next page
previous: string | null; // URL or cursor for previous page
results: T[]; // Current page results
}