✨(front) paginate maildomain and mailboxes views

Add pagination on maildomain and mailboxes data grid to allow navigation
in large list of items
This commit is contained in:
jbpenrath
2025-08-12 14:04:38 +02:00
committed by Jean-Baptiste PENRATH
parent 53110862d6
commit cf66702adc
13 changed files with 182 additions and 61 deletions
+12 -4
View File
@@ -376,7 +376,11 @@ class IsMailboxAdmin(permissions.BasePermission):
except (models.Mailbox.DoesNotExist, ValueError): # ValueError for invalid UUID
return False
# Check 1: Is user an admin of the specific mailbox?
# Check 1 : Is user a super user ?
if user.is_superuser:
return True
# Check 2: Is user an admin of the specific mailbox?
is_mailbox_admin = models.MailboxAccess.objects.filter(
user=user, mailbox=target_mailbox, role=models.MailboxRoleChoices.ADMIN
).exists()
@@ -384,7 +388,7 @@ class IsMailboxAdmin(permissions.BasePermission):
if is_mailbox_admin:
return True
# Check 2: Is user an admin of the mailbox's domain?
# Check 3: Is user an admin of the mailbox's domain?
if target_mailbox.domain:
is_domain_admin = models.MailDomainAccess.objects.filter(
user=user,
@@ -412,7 +416,11 @@ class IsMailboxAdmin(permissions.BasePermission):
user = request.user
target_mailbox = obj.mailbox # The mailbox related to the MailboxAccess object
# Check 1: Is user an admin of this specific mailbox?
# Check 1 : Is user a super user ?
if user.is_superuser:
return True
# Check 2: Is user an admin of this specific mailbox?
is_mailbox_admin = models.MailboxAccess.objects.filter(
user=user, mailbox=target_mailbox, role=models.MailboxRoleChoices.ADMIN
).exists()
@@ -420,7 +428,7 @@ class IsMailboxAdmin(permissions.BasePermission):
if is_mailbox_admin:
return True
# Check 2: Is user an admin of the mailbox's domain?
# Check 3: Is user an admin of the mailbox's domain?
is_domain_admin = models.MailDomainAccess.objects.filter(
user=user,
maildomain=target_mailbox.domain,
+1 -3
View File
@@ -147,9 +147,7 @@ class AdminMailDomainMailboxViewSet(
def get_queryset(self):
maildomain_pk = self.kwargs.get("maildomain_pk")
return models.Mailbox.objects.filter(domain_id=maildomain_pk).order_by(
"local_part"
)
return models.Mailbox.objects.filter(domain_id=maildomain_pk)
@extend_schema(
description="Create new mailbox in a specific maildomain.",
@@ -1,6 +1,7 @@
"""Tests for the MailboxAccessViewSet API endpoint (nested under mailboxes)."""
# pylint: disable=unused-argument
from django.contrib.auth.models import AnonymousUser
from django.urls import reverse
import pytest
@@ -13,6 +14,13 @@ pytestmark = pytest.mark.django_db
# --- Users ---
@pytest.fixture(name="super_user")
def fixture_super_user():
"""User with ADMIN access to mail_domain1."""
user = factories.UserFactory(is_superuser=True, is_staff=False)
return user
@pytest.fixture(name="domain_admin_user")
def fixture_domain_admin_user(mail_domain1):
"""User with ADMIN access to mail_domain1."""
@@ -131,6 +139,29 @@ class TestMailboxAccessViewSet:
)
# --- LIST Tests ---
def test_list_as_super_user_for_managed_mailbox(
self,
api_client,
super_user,
mailbox1_domain1,
access_m1d1_alpha,
access_m1d1_beta,
):
"""Super user should see accesses for the specified mailbox."""
api_client.force_authenticate(
user=super_user
) # Admin for domain1, which mailbox1_domain1 is in
response = api_client.get(self.list_create_url(mailbox_id=mailbox1_domain1.pk))
assert response.status_code == status.HTTP_200_OK
# access_m2d1_alpha is for a different mailbox, so should not be listed here.
assert {item["id"] for item in response.data["results"]} == {
str(access_m1d1_alpha.pk),
str(access_m1d1_beta.pk),
}
assert response.data["count"] == 2
def test_list_as_domain_admin_for_managed_mailbox(
self,
api_client,
@@ -209,11 +240,14 @@ class TestMailboxAccessViewSet:
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# --- CREATE Tests ---
@pytest.mark.parametrize("admin_type", ["domain_admin", "mailbox_admin"])
@pytest.mark.parametrize(
"admin_type", ["domain_admin", "mailbox_admin", "super_user"]
)
def test_create_access_success(
self,
api_client,
admin_type,
super_user,
domain_admin_user,
mailbox1_admin_user,
mailbox1_domain1,
@@ -221,9 +255,14 @@ class TestMailboxAccessViewSet:
user_alpha,
):
"""Domain and mailbox admins should be able to create new accesses."""
user_performing_action = (
domain_admin_user if admin_type == "domain_admin" else mailbox1_admin_user
)
user_performing_action = AnonymousUser()
if admin_type == "super_user":
user_performing_action = super_user
elif admin_type == "domain_admin":
user_performing_action = domain_admin_user
elif admin_type == "mailbox_admin":
user_performing_action = mailbox1_admin_user
api_client.force_authenticate(user=user_performing_action)
data = { # No 'mailbox' field in data, it comes from URL
@@ -288,20 +327,28 @@ class TestMailboxAccessViewSet:
assert response.status_code == status.HTTP_403_FORBIDDEN
# --- RETRIEVE Tests ---
@pytest.mark.parametrize("admin_type", ["domain_admin", "mailbox_admin"])
@pytest.mark.parametrize(
"admin_type", ["domain_admin", "mailbox_admin", "super_user"]
)
def test_retrieve_access_success(
self,
api_client,
admin_type,
super_user,
domain_admin_user,
mailbox1_admin_user,
mailbox1_domain1,
access_m1d1_alpha,
):
"""Domain and mailbox admins should be able to retrieve mailbox access details."""
user_performing_action = (
domain_admin_user if admin_type == "domain_admin" else mailbox1_admin_user
)
"""Super user, Domain and mailbox admins should be able to retrieve mailbox access details."""
user_performing_action = AnonymousUser()
if admin_type == "super_user":
user_performing_action = super_user
elif admin_type == "domain_admin":
user_performing_action = domain_admin_user
elif admin_type == "mailbox_admin":
user_performing_action = mailbox1_admin_user
api_client.force_authenticate(user=user_performing_action)
response = api_client.get(
self.detail_url(mailbox_id=mailbox1_domain1.pk, pk=access_m1d1_alpha.pk)
@@ -326,11 +373,14 @@ class TestMailboxAccessViewSet:
assert response.status_code == status.HTTP_404_NOT_FOUND
# --- UPDATE Tests ---
@pytest.mark.parametrize("admin_type", ["domain_admin", "mailbox_admin"])
@pytest.mark.parametrize(
"admin_type", ["domain_admin", "mailbox_admin", "super_user"]
)
def test_update_access_role_success(
self,
admin_type,
api_client,
super_user,
user_beta,
domain_admin_user,
mailbox1_admin_user,
@@ -338,9 +388,14 @@ class TestMailboxAccessViewSet:
access_m1d1_alpha,
):
"""Test that domain and mailbox admins can update mailbox access roles."""
user_performing_action = (
domain_admin_user if admin_type == "domain_admin" else mailbox1_admin_user
)
user_performing_action = AnonymousUser()
if admin_type == "super_user":
user_performing_action = super_user
elif admin_type == "domain_admin":
user_performing_action = domain_admin_user
elif admin_type == "mailbox_admin":
user_performing_action = mailbox1_admin_user
api_client.force_authenticate(user=user_performing_action)
data = {"role": "admin"}
response = api_client.patch(
@@ -366,20 +421,28 @@ class TestMailboxAccessViewSet:
assert response.status_code == status.HTTP_400_BAD_REQUEST
# --- DELETE Tests ---
@pytest.mark.parametrize("admin_type", ["domain_admin", "mailbox_admin"])
@pytest.mark.parametrize(
"admin_type", ["domain_admin", "mailbox_admin", "super_user"]
)
def test_delete_access_success(
self,
api_client,
admin_type,
super_user,
domain_admin_user,
mailbox1_admin_user,
mailbox1_domain1,
access_m1d1_alpha,
):
"""Test that domain and mailbox admins can delete mailbox accesses."""
user_performing_action = (
domain_admin_user if admin_type == "domain_admin" else mailbox1_admin_user
)
user_performing_action = AnonymousUser()
if admin_type == "super_user":
user_performing_action = super_user
elif admin_type == "domain_admin":
user_performing_action = domain_admin_user
elif admin_type == "mailbox_admin":
user_performing_action = mailbox1_admin_user
api_client.force_authenticate(user=user_performing_action)
response = api_client.delete(
self.detail_url(mailbox_id=mailbox1_domain1.pk, pk=access_m1d1_alpha.pk)
@@ -403,6 +403,8 @@
"datagrid_row_labels": {
"no_accesses": "No accesses",
"unknown_user": "Unknown user",
"other_user_one": "and 1 other user",
"other_user_other": "and {{count}} other users",
"alias": "Redirection",
"personal_mailbox": "Personal mailbox",
"shared_mailbox": "Shared mailbox"
@@ -844,6 +846,8 @@
"datagrid_row_labels": {
"no_accesses": "Aucun accès",
"unknown_user": "Utilisateur inconnu",
"other_user_one": "et 1 autre utilisateur",
"other_user_other": "et {{count}} autres utilisateurs",
"alias": "Redirection",
"personal_mailbox": "Boîte personnelle",
"shared_mailbox": "Boîte partagée"
@@ -4,11 +4,15 @@ import useAbility, { Abilities } from "@/hooks/use-ability";
import { Button, useModal } from "@openfun/cunningham-react";
import { useTranslation } from "react-i18next";
type CreateMailboxActionProps = {
onCreate: () => void;
}
/**
* Action button to create a new mailbox.
* Only visible if the user has the ability to manage mailboxes.
*/
export const CreateMailboxAction = () => {
export const CreateMailboxAction = ({ onCreate }: CreateMailboxActionProps) => {
const modal = useModal();
const { t } = useTranslation();
const { selectedMailDomain } = useAdminMailDomain();
@@ -26,6 +30,7 @@ export const CreateMailboxAction = () => {
<ModalCreateAddress
isOpen={modal.isOpen}
onClose={modal.close}
onCreate={onCreate}
/>
</>
)
@@ -3,23 +3,23 @@ import { ModalMailboxManageAccesses } from "@/features/layouts/components/admin/
import { Banner } from "@/features/ui/components/banner";
import useAbility, { Abilities } from "@/hooks/use-ability";
import { Spinner } from "@gouvfr-lasuite/ui-kit";
import { Button, DataGrid } from "@openfun/cunningham-react";
import { useState } from "react";
import { Button, DataGrid, usePagination } from "@openfun/cunningham-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
type AdminUserDataGridProps = {
domain: MailDomainAdmin;
pagination: ReturnType<typeof usePagination>;
}
export const AdminMailboxDataGrid = ({ domain }: AdminUserDataGridProps) => {
export const AdminMailboxDataGrid = ({ domain, pagination }: AdminUserDataGridProps) => {
const { t } = useTranslation();
const { data: mailboxesData, isLoading, error, refetch: refetchMailboxes } = useMaildomainsMailboxesList(domain.id);
const { data: mailboxesData, isLoading, error, refetch: refetchMailboxes } = useMaildomainsMailboxesList(domain.id, { page: pagination.page });
const mailboxes = mailboxesData?.data.results || [];
const [editedMailboxId, setEditedMailboxId] = useState<string | null>(null);
const editedMailbox = mailboxes.find((mailbox) => mailbox.id === editedMailboxId);
const [editedMailbox, setEditedMailbox] = useState<MailboxAdmin | null>(null);
const canManageMailboxes = useAbility(Abilities.CAN_MANAGE_MAILDOMAIN_MAILBOXES, domain);
const handleCloseEditUserModal = (refetch: boolean = false) => {
setEditedMailboxId(null);
setEditedMailbox(null);
if (refetch) {
refetchMailboxes();
}
@@ -58,7 +58,7 @@ export const AdminMailboxDataGrid = ({ domain }: AdminUserDataGridProps) => {
renderCell: ({ row }: { row: MailboxAdmin }) => `${row.local_part}@${row.domain_name}`,
},
{
id: "user_name",
id: "accesses",
headerName: t("admin_maildomains_details.datagrid_headers.accesses"),
renderCell: ({ row }: { row: MailboxAdmin }) => {
if (row.accesses?.length === 0) {
@@ -69,9 +69,10 @@ export const AdminMailboxDataGrid = ({ domain }: AdminUserDataGridProps) => {
);
}
return row.accesses?.map((access) => {
const otherAccessesCount = row.accesses?.length - 2;
return row.accesses?.slice(0, 2).map((access) => {
return access.user?.full_name || access.user?.email || t("admin_maildomains_details.datagrid_row_labels.unknown_user");
}).join(", ");
}).join(", ") + (otherAccessesCount > 0 ? ` ${t("admin_maildomains_details.datagrid_row_labels.other_user", { count: otherAccessesCount })}` : "");
},
},
...(canManageMailboxes ? [{
@@ -84,7 +85,7 @@ export const AdminMailboxDataGrid = ({ domain }: AdminUserDataGridProps) => {
color="secondary"
size="small"
onClick={() => {
setEditedMailboxId(row.id);
setEditedMailbox(row);
}}
>
{t('admin_maildomains_details.actions.manage_accesses')}
@@ -94,6 +95,19 @@ export const AdminMailboxDataGrid = ({ domain }: AdminUserDataGridProps) => {
}] : []),
];
useEffect(() => {
if (!pagination.pagesCount && mailboxesData?.data.count) {
pagination.setPagesCount(Math.ceil(mailboxesData.data.count / pagination.pageSize));
}
}, [mailboxesData?.data.count, pagination.pageSize]);
useEffect(() => {
if (editedMailbox) {
const updatedMailbox = mailboxes.find((mailbox) => mailbox.id === editedMailbox.id);
if (updatedMailbox) setEditedMailbox(updatedMailbox);
}
}, [mailboxes, editedMailbox]);
if (isLoading) {
return (
<div className="admin-data-grid">
@@ -119,6 +133,9 @@ export const AdminMailboxDataGrid = ({ domain }: AdminUserDataGridProps) => {
<DataGrid
columns={columns}
rows={mailboxes}
pagination={pagination}
enableSorting={false}
onSortModelChange={() => undefined}
/>
{canManageMailboxes && (
<ModalMailboxManageAccesses
@@ -2,8 +2,13 @@ import { Spinner } from "@gouvfr-lasuite/ui-kit";
import { AdminMailboxDataGrid } from "./mailbox-data-grid";
import { useAdminMailDomain } from "@/features/providers/admin-maildomain";
import { useTranslation } from "react-i18next";
import { usePagination } from "@openfun/cunningham-react";
export const AdminDomainPageContent = () => {
type AdminDomainPageContentProps = {
pagination: ReturnType<typeof usePagination>;
}
export const AdminDomainPageContent = ({ pagination }: AdminDomainPageContentProps) => {
const { t } = useTranslation();
const { selectedMailDomain, isLoading } = useAdminMailDomain();
@@ -23,5 +28,5 @@ export const AdminDomainPageContent = () => {
);
}
return <AdminMailboxDataGrid domain={selectedMailDomain} />;
return <AdminMailboxDataGrid domain={selectedMailDomain} pagination={pagination} />;
}
@@ -37,9 +37,10 @@ const slugify = (text: string): string => {
type ModalCreateAddressProps = {
isOpen: boolean;
onClose: () => void;
onCreate: () => void;
}
export const ModalCreateAddress = ({ isOpen, onClose }: ModalCreateAddressProps) => {
export const ModalCreateAddress = ({ isOpen, onClose, onCreate }: ModalCreateAddressProps) => {
const { t } = useTranslation();
const router = useRouter();
const domainId = router.query.maildomainId as string;
@@ -209,6 +210,7 @@ export const ModalCreateAddress = ({ isOpen, onClose }: ModalCreateAddressProps)
const response = await createMailbox({ maildomainPk: domainId, data: payload }, );
refetchMailboxes();
setCreatedMailbox(response.data);
onCreate();
} catch (error: unknown) {
if (error instanceof APIError && error.data.local_part) {
setError("create_address_modal.api_errors.prefix_exists");
@@ -7,7 +7,7 @@ type ModalMailboxManageAccessesProps = {
domainId: string;
isOpen: boolean;
onClose: () => void;
mailbox: MailboxAdmin | undefined;
mailbox: MailboxAdmin | null;
onAccessChange: () => void;
}
@@ -1,13 +1,15 @@
import { createContext, PropsWithChildren, useContext, useEffect, useMemo, useState } from "react"
import { createContext, PropsWithChildren, useContext, useEffect, useMemo } from "react"
import { MailDomainAdmin } from "../api/gen/models/mail_domain_admin";
import { useMaildomainsList } from "../api/gen";
import { useMaildomainsList, useMaildomainsRetrieve } from "../api/gen";
import { useRouter } from "next/router";
import { usePagination } from "@openfun/cunningham-react";
type AdminMailDomainContextType = {
selectedMailDomain: MailDomainAdmin | null;
mailDomains: MailDomainAdmin[];
isLoading: boolean;
error: unknown | null;
pagination: ReturnType<typeof usePagination>;
}
const AdminMailDomainContext = createContext<AdminMailDomainContextType | undefined>(undefined)
@@ -17,24 +19,24 @@ const AdminMailDomainContext = createContext<AdminMailDomainContextType | undefi
* It centralizes mail domain data fetching and selection.
*/
export const AdminMailDomainProvider = ({ children }: PropsWithChildren) => {
const { data: maildomainsData, isLoading, error } = useMaildomainsList();
const router = useRouter();
const [selectedMailDomain, setSelectedMailDomain] = useState<MailDomainAdmin | null>(null);
const pagination = usePagination({ pageSize: 20 });
const { data: maildomainsData, isLoading: isLoadingList, error: listError } = useMaildomainsList({ page: pagination.page });
const { data: selectedMaildomainData, isLoading: isLoadingItem, error: itemError } = useMaildomainsRetrieve(
router.query.maildomainId as string, { query: { enabled: !!router.query.maildomainId } });
const context = useMemo(() => ({
selectedMailDomain,
selectedMailDomain: selectedMaildomainData?.data || null,
mailDomains: maildomainsData?.data.results || [],
isLoading,
error,
}), [selectedMailDomain, maildomainsData, isLoading]);
isLoading: isLoadingList || isLoadingItem,
error: listError || itemError,
pagination
}), [selectedMaildomainData, maildomainsData, isLoadingList, isLoadingItem, listError, itemError, pagination.page]);
useEffect(() => {
if (router.query.maildomainId) {
const maildomain = maildomainsData?.data.results?.find((maildomain) => maildomain.id === router.query.maildomainId);
if (maildomain) {
setSelectedMailDomain(maildomain);
}
if (maildomainsData?.data.count) {
pagination.setPagesCount(Math.ceil(maildomainsData.data.count / pagination.pageSize));
}
}, [router.query.maildomainId, maildomainsData]);
}, [maildomainsData?.data.count, pagination.pageSize, pagination.setPagesCount]);
return (
<AdminMailDomainContext.Provider value={context}>{children}</AdminMailDomainContext.Provider>
@@ -1,17 +1,25 @@
import { AdminLayout } from "@/features/layouts/components/admin/admin-layout";
import { CreateMailboxAction } from "@/features/layouts/components/admin/mailboxes-view/create-mailbox-action";
import { AdminDomainPageContent } from "@/features/layouts/components/admin/mailboxes-view/page-content";
import { usePagination } from "@openfun/cunningham-react";
/**
* Admin page which list all mailboxes for a given domain and allow to manage them.
*/
export default function AdminDomainPage() {
export default function AdminDomainMailboxesPage() {
const pagination = usePagination({ pageSize: 20 });
const handleCreateMailbox = () => {
pagination.setPage(1);
pagination.setPagesCount(undefined)
}
return (
<AdminLayout
currentTab="addresses"
actions={<CreateMailboxAction />}
actions={<CreateMailboxAction onCreate={handleCreateMailbox} />}
>
<AdminDomainPageContent />
<AdminDomainPageContent pagination={pagination} />
</AdminLayout>
);
}
+8 -4
View File
@@ -1,5 +1,5 @@
import { useEffect } from "react";
import { DataGrid } from "@openfun/cunningham-react";
import { DataGrid, usePagination } from "@openfun/cunningham-react";
import { useRouter } from "next/router";
import { useTranslation } from "react-i18next";
import { Spinner } from "@gouvfr-lasuite/ui-kit";
@@ -11,10 +11,11 @@ import useAbility, { Abilities } from "@/hooks/use-ability";
import { Banner } from "@/features/ui/components/banner";
type AdminDataGridProps = {
pagination: ReturnType<typeof usePagination>;
domains: MailDomainAdmin[];
}
function AdminDataGrid({ domains }: AdminDataGridProps) {
function AdminDataGrid({ domains, pagination }: AdminDataGridProps) {
const router = useRouter();
const { t, i18n } = useTranslation();
@@ -48,6 +49,9 @@ function AdminDataGrid({ domains }: AdminDataGridProps) {
<DataGrid
columns={columns}
rows={domains}
pagination={pagination}
enableSorting={false}
onSortModelChange={() => undefined}
/>
</div>
);
@@ -56,7 +60,7 @@ function AdminDataGrid({ domains }: AdminDataGridProps) {
const AdminPageContent = () => {
const router = useRouter();
const { t } = useTranslation();
const { mailDomains, isLoading, error } = useAdminMailDomain();
const { mailDomains, isLoading, error, pagination } = useAdminMailDomain();
const canCreateMaildomain = useAbility(Abilities.CAN_CREATE_MAILDOMAINS);
const shouldRedirect = !canCreateMaildomain && !isLoading && mailDomains.length === 1;
@@ -91,7 +95,7 @@ const AdminPageContent = () => {
<Bar className="admin-page__bar">
<h1>{t("admin_maildomains_list.title")}</h1>
</Bar>
<AdminDataGrid domains={mailDomains} />
<AdminDataGrid domains={mailDomains} pagination={pagination} />
</>
)
}
+5
View File
@@ -49,6 +49,11 @@ body {
font-size: var(--c--theme--font--sizes--ml);
}
.c__datagrid .c__pagination .c__button--active {
background-color: var(--c--components--button--tertiary-text--background--color-hover);
color: var(--c--components--button--tertiary-text--color-hover);
}
// Related to @viselect/react
.selection-area {
background: rgba(46, 115, 252, 0.11);