(datagrid) add sort to mailboxes list + mail domain list (#1057)

add sort to mailboxes list + mail domain list

(invitations) allow delete invitations by an admin (#1052)

allow delete invitations mails domains access by an admin
This commit is contained in:
Eléonore
2026-03-16 17:23:29 +01:00
committed by GitHub
parent 5bc845172b
commit bc458b3f68
9 changed files with 160 additions and 86 deletions
+1
View File
@@ -22,6 +22,7 @@ and this project adheres to
- ✨(demo) add aliases to demo #1050
- ✨(front) add icon to button to configure a domain
- ✨(datagrid) add sort to mailboxes list + mail domain list
- ✨(invitations) allow delete invitations mails domains access by an admin
- ✨(front) delete invitations mails domains access
- ✨(front) add show invitations mails domains access #1040
@@ -7,6 +7,7 @@ import { Box, Text, TextErrors } from '@/components';
import { useAuthStore } from '@/core/auth';
import { Alias, AliasGroup } from '@/features/mail-domains/aliases/types';
import { MailDomain } from '@/features/mail-domains/domains';
import { sortData } from '@/utils/sortData';
import { useAliasesInfinite } from '../../api/useAliasesInfinite';
import { ModalEditAlias } from '../ModalEditAlias';
@@ -23,15 +24,6 @@ interface AliasesListViewProps {
querySearch: string;
}
type SortModelItem = {
field: string;
sort: 'asc' | 'desc' | null;
};
function formatSortModel(sortModel: SortModelItem) {
return sortModel.sort === 'desc' ? `-${sortModel.field}` : sortModel.field;
}
export function AliasesListView({
mailDomain,
querySearch,
@@ -42,9 +34,13 @@ export function AliasesListView({
null,
);
const [sortModel] = useState<SortModel>([]);
const [sortModel, setSortModel] = useState<SortModel>([
{
field: 'local_part',
sort: 'desc',
},
]);
const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined;
const {
data,
isLoading,
@@ -54,7 +50,6 @@ export function AliasesListView({
isFetchingNextPage,
} = useAliasesInfinite({
mailDomainSlug: mailDomain.slug,
ordering,
}) as {
data: InfiniteData<MailDomainAliasesResponse, number> | undefined;
isLoading: boolean;
@@ -90,7 +85,7 @@ export function AliasesListView({
local_part: alias.local_part,
destinations: [alias.destination],
destinationIds,
count_destinations: 1,
count_destinations: [alias.destination].length,
});
}
});
@@ -100,18 +95,21 @@ export function AliasesListView({
}, [data, mailDomain]);
const filteredAliases = useMemo(() => {
if (!querySearch) {
return aliasGroups;
let result = aliasGroups;
if (querySearch) {
const lowerCaseSearch = querySearch.toLowerCase();
result = result.filter(
(alias) =>
alias.email.toLowerCase().includes(lowerCaseSearch) ||
alias.destinations.some((dest) =>
dest.toLowerCase().includes(lowerCaseSearch),
),
);
}
const lowerCaseSearch = querySearch.toLowerCase();
return aliasGroups.filter(
(alias) =>
alias.email.toLowerCase().includes(lowerCaseSearch) ||
alias.destinations.some((dest) =>
dest.toLowerCase().includes(lowerCaseSearch),
),
);
}, [querySearch, aliasGroups]);
return sortData(result, sortModel);
}, [querySearch, aliasGroups, sortModel]);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
@@ -155,7 +153,7 @@ export function AliasesListView({
rows={filteredAliases}
columns={[
{
field: 'email',
field: 'local_part',
headerName: `${t('Alias')}${filteredAliases.length}`,
renderCell: ({ row }) => (
<Text $weight="400" $theme="greyscale">
@@ -164,9 +162,8 @@ export function AliasesListView({
),
},
{
field: 'destinations',
field: 'count_destinations',
headerName: t('Destinations'),
enableSorting: false,
renderCell: ({ row }) => (
<Text $weight="500" $theme="greyscale">
{row.count_destinations} destination
@@ -205,6 +202,8 @@ export function AliasesListView({
},
]}
isLoading={isLoading}
sortModel={sortModel}
onSortModelChange={setSortModel}
/>
{isFetchingNextPage && <div>{t('Loading more...')}</div>}
</>
@@ -17,7 +17,7 @@ export enum EnumMailDomainsOrdering {
}
export type MailDomainsParams = {
ordering: EnumMailDomainsOrdering;
ordering?: string | EnumMailDomainsOrdering;
};
type MailDomainsAPIParams = MailDomainsParams & {
@@ -1,43 +1,65 @@
import { Button, DataGrid } from '@openfun/cunningham-react';
import { useEffect, useMemo, useRef } from 'react';
import { Button, DataGrid, SortModel } from '@openfun/cunningham-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, StyledLink, Tag, Text } from '@/components';
import {
MailDomain,
useMailDomains,
useMailDomainsStore,
} from '@/features/mail-domains/domains';
import { MailDomain, useMailDomains } from '@/features/mail-domains/domains';
import { sortData } from '@/utils/sortData';
interface MailDomainsListViewProps {
querySearch: string;
}
type ViewMailDomain = {
id: MailDomain['id'];
name: string;
count_mailboxes?: number;
status: MailDomain['status'];
slug: string;
mailDomain: MailDomain;
};
export function MailDomainsListView({ querySearch }: MailDomainsListViewProps) {
const { t } = useTranslation();
const { ordering } = useMailDomainsStore();
const [sortModel, setSortModel] = useState<SortModel>([
{
field: 'name',
sort: 'desc',
},
]);
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
useMailDomains({ ordering });
const mailDomains = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return acc.concat(page.results);
}, [] as MailDomain[]);
}, [data?.pages]);
useMailDomains({});
const mailDomains: ViewMailDomain[] = useMemo(() => {
if (!data?.pages?.length) {
return [];
}
return data.pages.flatMap((page) =>
page.results.map((mailDomain: MailDomain) => ({
id: mailDomain.id,
name: mailDomain.name,
count_mailboxes: mailDomain.count_mailboxes,
status: mailDomain.status,
slug: mailDomain.slug,
mailDomain: mailDomain,
})),
);
}, [data]);
const filteredMailDomains = useMemo(() => {
if (!querySearch) {
return mailDomains;
let result = mailDomains;
if (querySearch) {
const lowerCaseSearch = querySearch.toLowerCase();
result = result.filter((domain) =>
domain.name.toLowerCase().includes(lowerCaseSearch),
);
}
const lowerCaseSearch = querySearch.toLowerCase();
return (
(mailDomains &&
mailDomains.filter((domain) =>
domain.name.toLowerCase().includes(lowerCaseSearch),
)) ||
[]
);
}, [querySearch, mailDomains]);
return sortData(result, sortModel);
}, [querySearch, mailDomains, sortModel]);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
@@ -74,17 +96,15 @@ export function MailDomainsListView({ querySearch }: MailDomainsListViewProps) {
{
field: 'name',
headerName: `${t('Domain')} (${filteredMailDomains.length})`,
enableSorting: true,
},
{
field: 'count_mailboxes',
headerName: `${t('Number of mailboxes')}`,
enableSorting: true,
},
{
id: 'status',
field: 'status',
headerName: `${t('Status')}`,
enableSorting: true,
renderCell({ row }) {
return (
<Box $direction="row" $align="center">
@@ -120,6 +140,8 @@ export function MailDomainsListView({ querySearch }: MailDomainsListViewProps) {
},
]}
isLoading={isLoading}
sortModel={sortModel}
onSortModelChange={setSortModel}
/>
) : null}
<div ref={loadMoreRef} style={{ height: 32 }} />
@@ -294,11 +294,8 @@ const AlertStatus = ({ status }: { status: MailDomain['status'] }) => {
message: (
<Text $display="inline">
{t(
'The domain name encounters an error. Please contact our support team to solve the problem: ',
'The domain name encounters an error. Please contact our support team to solve the problem. ',
)}
<a href="mailto:suiteterritoriale@anct.gouv.fr">
suiteterritoriale@anct.gouv.fr
</a>
</Text>
),
};
@@ -75,9 +75,11 @@ describe('MailBoxesView', () => {
});
it('renders with no mailboxes and displays empty placeholder', async () => {
fetchMock.get('end:/mail-domains/example-com/mailboxes/?page=1', {
fetchMock.get(/mail-domains\/example-com\/mailboxes\/\?page=1/, {
count: 0,
results: [],
next: null,
previous: null,
});
render(<MailBoxesView mailDomain={mockMailDomain} />, {
@@ -90,25 +92,34 @@ describe('MailBoxesView', () => {
});
it('renders mailboxes and displays them correctly', async () => {
fetchMock.get('end:/mail-domains/example-com/mailboxes/?page=1', {
fetchMock.get(/mail-domains\/example-com\/mailboxes\/\?page=1/, {
count: 2,
results: mockMailboxes,
next: null,
previous: null,
});
render(<MailBoxesView mailDomain={mockMailDomain} />, {
const mailDomainWithCount = {
...mockMailDomain,
count_mailboxes: 2,
};
render(<MailBoxesView mailDomain={mailDomainWithCount} />, {
wrapper: AppWrapper,
});
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Doe John')).toBeInTheDocument();
});
expect(screen.getByText('jane.smith@example.com')).toBeInTheDocument();
});
it('opens the create mailbox modal when button is clicked by granted user', async () => {
fetchMock.get('end:/mail-domains/example-com/mailboxes/?page=1', {
fetchMock.get(/mail-domains\/example-com\/mailboxes\/\?page=1/, {
count: 0,
results: [],
next: null,
previous: null,
});
render(<MailBoxesView mailDomain={mockMailDomain} />, {
@@ -123,9 +134,11 @@ describe('MailBoxesView', () => {
});
it('displays the correct alert based on mail domain status', async () => {
fetchMock.get('end:/mail-domains/example-com/mailboxes/?page=1', {
fetchMock.get(/mail-domains\/example-com\/mailboxes\/\?page=1/, {
count: 0,
results: [],
next: null,
previous: null,
});
const statuses = [
@@ -154,7 +167,7 @@ describe('MailBoxesView', () => {
});
it('handles API errors and displays the error message', async () => {
fetchMock.get('end:/mail-domains/example-com/mailboxes/?page=1', {
fetchMock.get(/mail-domains\/example-com\/mailboxes\/\?page=1/, {
status: 500,
body: {
cause: 'An unexpected error occurred.',
@@ -171,9 +184,11 @@ describe('MailBoxesView', () => {
});
it('hides buttons to ungranted users', async () => {
fetchMock.get('end:/mail-domains/example-com/mailboxes/?page=1', {
fetchMock.get(/mail-domains\/example-com\/mailboxes\/\?page=1/, {
count: 0,
results: [],
next: null,
previous: null,
});
render(<MailBoxesView mailDomain={mockMailDomainAsViewer} />, {
@@ -43,7 +43,12 @@ export function MailBoxesListView({
const { t } = useTranslation();
const { userData } = useAuthStore();
const [sortModel] = useState<SortModel>([]);
const [sortModel, setSortModel] = useState<SortModel>([
{
field: 'local_part',
sort: 'desc',
},
]);
const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined;
const {
@@ -69,6 +74,10 @@ export function MailBoxesListView({
if (!mailDomain || !data?.pages?.length) {
return [];
}
const capitalize = (value: string) =>
value ? value.charAt(0).toUpperCase() + value.slice(1) : value;
return data.pages.flatMap((page) =>
page.results.map((mailbox: MailDomainMailbox) => {
const email = `${mailbox.local_part}@${mailDomain.name}`;
@@ -77,7 +86,9 @@ export function MailBoxesListView({
return {
id: mailbox.id,
email,
name: `${mailbox.first_name} ${mailbox.last_name}`,
name: `${capitalize(mailbox.last_name)} ${capitalize(
mailbox.first_name,
)}`,
first_name: mailbox.first_name,
last_name: mailbox.last_name,
local_part: mailbox.local_part,
@@ -136,7 +147,7 @@ export function MailBoxesListView({
rows={filteredMailboxes}
columns={[
{
field: 'email',
field: 'local_part',
headerName: `${t('Address')}${filteredMailboxes.length}`,
renderCell: ({ row }) => (
<Text
@@ -148,23 +159,18 @@ export function MailBoxesListView({
),
},
{
field: 'name',
field: 'last_name',
headerName: t('User'),
enableSorting: true,
renderCell: ({ row }) => (
<Text
$weight="500"
$theme="greyscale"
$css="text-transform: capitalize;"
>
<Text $weight="500" $theme="greyscale">
{row.name}
</Text>
),
},
{
field: 'status',
id: 'status',
headerName: t('Status'),
enableSorting: true,
renderCell({ row }) {
return (
<Box $direction="row" $align="center">
@@ -185,6 +191,8 @@ export function MailBoxesListView({
},
]}
isLoading={isLoading}
sortModel={sortModel}
onSortModelChange={setSortModel}
/>
{isFetchingNextPage && <div>{t('Loading more...')}</div>}
</>
@@ -0,0 +1,36 @@
import { SortModel } from '@openfun/cunningham-react';
export function sortData<T extends object>(
data: T[],
sortModel: SortModel,
): T[] {
const [sort] = sortModel;
if (!sort?.sort) {
return data;
}
const field: string = Array.isArray(sort.field)
? String(sort.field[0])
: String(sort.field);
const direction = sort.sort === 'asc' ? 1 : -1;
return [...data].sort((a, b) => {
const aRecord = a as Record<string, unknown>;
const bRecord = b as Record<string, unknown>;
const aValue = aRecord[field] as string | number | null | undefined;
const bValue = bRecord[field] as string | number | null | undefined;
if (typeof aValue === 'number' && typeof bValue === 'number') {
return direction * (aValue - bValue);
}
return (
direction *
String(aValue ?? '').localeCompare(String(bValue ?? ''), undefined, {
sensitivity: 'base',
})
);
});
}
@@ -73,7 +73,7 @@ const assertFilledMailboxesTableElementsAreVisible = async (
const table = page.locator('table');
await expect(table).toBeVisible();
const tdNames = await table.getByText('John Doe').all();
const tdNames = await table.getByText('Doe John').all();
expect(tdNames.length).toEqual(20);
};
@@ -542,14 +542,10 @@ test.describe('Mail domain', () => {
await expect(
page.getByText(
'The domain name encounters an error. Please contact our support team to solve the problem:',
'The domain name encounters an error. Please contact our support team to solve the problem.',
),
).toBeVisible();
await expect(
page.getByRole('link', { name: 'suiteterritoriale@anct.gouv.fr' }),
).toBeVisible();
await expect(page.getByTestId('button-new-mailbox')).toBeDisabled();
await expect(
@@ -971,7 +967,7 @@ test.describe('Mail domain', () => {
await expect(
page.getByText(
'The domain name encounters an error. Please contact our support team to solve the problem:',
'The domain name encounters an error. Please contact our support team to solve the problem.',
),
).toBeVisible();