diff --git a/backend/samfundet/pagination.py b/backend/samfundet/pagination.py new file mode 100644 index 000000000..114fa4e3c --- /dev/null +++ b/backend/samfundet/pagination.py @@ -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') diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index e4099a47c..73171a1f4 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -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 @@ -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): diff --git a/frontend/src/PagesAdmin/UsersAdminPage/UserAdminPage.module.scss b/frontend/src/PagesAdmin/UsersAdminPage/UserAdminPage.module.scss new file mode 100644 index 000000000..63f5fc8e6 --- /dev/null +++ b/frontend/src/PagesAdmin/UsersAdminPage/UserAdminPage.module.scss @@ -0,0 +1,5 @@ +.container { + display: flex; + flex-direction: column; + gap: 1rem; +} diff --git a/frontend/src/PagesAdmin/UsersAdminPage/UsersAdminPage.tsx b/frontend/src/PagesAdmin/UsersAdminPage/UsersAdminPage.tsx index 7fd36c020..4dd5de311 100644 --- a/frontend/src/PagesAdmin/UsersAdminPage/UsersAdminPage.tsx +++ b/frontend/src/PagesAdmin/UsersAdminPage/UsersAdminPage.tsx @@ -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(); - 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({ + queryKey: ['admin-users'], + queryFn: (page: number) => getUsers(page), + }); const columns = [ { content: t(KEY.common_username), sortable: true }, @@ -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: , - }, - ], - }; - }); - }, [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: , + }, + ], + }; + }); return ( - - + + {totalPages > 1 && ( + + )} + + {isLoading ? :
} ); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 9a7997519..3a22f0e00 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 { const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__csrf; @@ -105,9 +106,22 @@ export async function impersonateUser(userId: number): Promise { return response.status === 200; } -export async function getUsers(): Promise { - const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__users; - const response = await axios.get(url, { withCredentials: true }); +// export async function getUsers(page?: number): Promise> { +// const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__users; +// if (page) { +// const paginatedUrl = `${url}?page=${page}`; +// const response = await axios.get>(paginatedUrl, { withCredentials: true }); +// return response.data; +// } + +// const response = await axios.get(url, { withCredentials: true }); +// return response.data; +// } + +export async function getUsers(page?: number): Promise> { + const url = `${BACKEND_DOMAIN + ROUTES.backend.samfundet__users}?page=${page}`; + const response = await axios.get>(url, { withCredentials: true }); + console.log(response.data); return response.data; } diff --git a/frontend/src/constants/constants.ts b/frontend/src/constants/constants.ts index e8127e4cb..0f37fc54f 100644 --- a/frontend/src/constants/constants.ts +++ b/frontend/src/constants/constants.ts @@ -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; diff --git a/frontend/src/hooks.ts b/frontend/src/hooks.ts index 70512a9c5..55e688f73 100644 --- a/frontend/src/hooks.ts +++ b/frontend/src/hooks.ts @@ -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'; @@ -503,3 +512,44 @@ export function useParentElementWidth(childRef: RefObject) { return parentWidth; } + +interface UsePaginatedQueryOptions { + queryKey: string[]; + queryFn: (page: number) => Promise>; + pageSize?: number; +} + +interface UsePaginatedQueryResult { + data: T[]; + totalItems: number; + currentPage: number; + totalPages: number; + pageSize: number; + setCurrentPage: (page: number) => void; + isLoading: boolean; + error: Error | null; +} + +export function usePaginatedQuery({ + queryKey, + queryFn, + //initialPage = 1, +}: UsePaginatedQueryOptions): UsePaginatedQueryResult { + 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, + }; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2a874b674..107153b0d 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 { + 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 { + next: string | null; // URL or cursor for next page + previous: string | null; // URL or cursor for previous page + results: T[]; // Current page results +}