✨(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:
@@ -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
|
||||
|
||||
+26
-27
@@ -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 & {
|
||||
|
||||
+50
-28
@@ -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 }} />
|
||||
|
||||
+1
-4
@@ -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>
|
||||
),
|
||||
};
|
||||
|
||||
+23
-8
@@ -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} />, {
|
||||
|
||||
+19
-11
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user