(front) add modal update mailboxes (#954)

* (front) add modal update mailboxes

add modal update mailboxes

* ️(labels) improve aria-labels on domain panel's buttons

improve descriptions on aria-label on domain panel's buttons

---------

Co-authored-by: Marie PUPO JEAMMET <marie.pupojeammet@numerique.gouv.fr>
This commit is contained in:
elvoisin
2025-09-03 11:02:23 +02:00
committed by GitHub
parent 1bfad507ef
commit f1892b7049
10 changed files with 385 additions and 20 deletions

View File

@@ -8,6 +8,8 @@ and this project adheres to
## [Unreleased]
- ✨(front) add modal update mailboxes #954
### Added
- ✨(api) update mailboxes #934

View File

@@ -24,6 +24,7 @@ export const PanelActions = () => {
$css={`
& button {
padding: 0;
justify-content: start;
svg {
padding: 0.1rem;

View File

@@ -1,2 +1,3 @@
export * from './useCreateMailbox';
export * from './useMailboxes';
export * from './useUpdateMailbox';

View File

@@ -0,0 +1,69 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_MAILBOX } from './useMailboxes';
export interface UpdateMailboxParams {
first_name: string;
last_name: string;
secondary_email: string;
mailDomainSlug: string;
}
export const updateMailbox = async ({
mailDomainSlug,
mailboxId,
...data
}: UpdateMailboxParams & { mailboxId: string }): Promise<void> => {
const response = await fetchAPI(
`mail-domains/${mailDomainSlug}/mailboxes/${mailboxId}/`,
{
method: 'PATCH',
body: JSON.stringify(data),
},
);
if (!response.ok) {
const errorData = await errorCauses(response);
console.log('Error data:', errorData);
throw new APIError('Failed to update the mailbox', {
status: errorData.status,
cause: errorData.cause as string[],
data: errorData.data,
});
}
};
type UseUpdateMailboxParams = {
mailDomainSlug: string;
mailboxId: string;
} & UseMutationOptions<void, APIError, UpdateMailboxParams>;
export const useUpdateMailbox = (options: UseUpdateMailboxParams) => {
const queryClient = useQueryClient();
return useMutation<void, APIError, UpdateMailboxParams>({
mutationFn: (data) =>
updateMailbox({ ...data, mailboxId: options.mailboxId }),
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [
KEY_LIST_MAILBOX,
{ mailDomainSlug: variables.mailDomainSlug },
],
});
if (options?.onSuccess) {
options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
options.onError(error, variables, context);
}
},
});
};

View File

@@ -0,0 +1,248 @@
import { zodResolver } from '@hookform/resolvers/zod';
import {
Button,
Loader,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import React, { useEffect, useState } from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { APIError } from '@/api/APIError';
import { parseAPIError } from '@/api/parseAPIError';
import {
Box,
HorizontalSeparator,
Input,
Text,
TextErrors,
} from '@/components';
import { CustomModal } from '@/components/modal/CustomModal';
import { MailDomain } from '../../domains/types';
import { useUpdateMailbox } from '../api/useUpdateMailbox';
import { ViewMailbox } from '../types';
const FORM_ID = 'form-update-mailbox';
interface ModalUpdateMailboxProps {
isOpen: boolean;
onClose: () => void;
mailDomain: MailDomain;
mailbox: ViewMailbox;
}
export const ModalUpdateMailbox = ({
isOpen,
onClose,
mailDomain,
mailbox,
}: ModalUpdateMailboxProps) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const [errorCauses, setErrorCauses] = useState<string[]>([]);
const [step] = useState(0);
const updateMailboxValidationSchema = z.object({
first_name: z.string().min(1, t('Please enter your first name')),
last_name: z.string().min(1, t('Please enter your last name')),
secondary_email: z.string().email(t('Please enter a valid email address')),
});
const methods = useForm({
resolver: zodResolver(updateMailboxValidationSchema),
defaultValues: {
first_name: mailbox?.first_name || '',
last_name: mailbox?.last_name || '',
secondary_email: mailbox?.secondary_email || '',
},
mode: 'onChange',
});
useEffect(() => {
if (mailbox && isOpen) {
methods.reset({
first_name: mailbox.first_name || '',
last_name: mailbox.last_name || '',
secondary_email: mailbox.secondary_email || '',
});
}
}, [mailbox, isOpen, methods]);
const { mutate: updateMailbox, isPending } = useUpdateMailbox({
mailDomainSlug: mailDomain.slug,
mailboxId: mailbox?.id || '',
onSuccess: () => {
toast(t('Mailbox updated!'), VariantType.SUCCESS, { duration: 4000 });
onClose();
},
onError: (error: APIError) => {
const causes =
parseAPIError({
error,
errorParams: [
[
['Invalid format'],
t('Invalid format for the email address.'),
undefined,
],
],
serverErrorParams: [
t(
'An error occurred while updating the mailbox. Please try again.',
),
undefined,
],
}) || [];
if (causes.length > 0) {
causes.forEach((cause) => {
toast(cause, VariantType.ERROR, { duration: 4000 });
});
} else {
toast(t('Mailbox update failed!'), VariantType.ERROR, {
duration: 4000,
});
}
setErrorCauses(causes);
},
});
if (!mailbox) {
return null;
}
const onSubmitCallback = (event: React.FormEvent) => {
event.preventDefault();
if (!mailbox?.id) {
return;
}
void methods.handleSubmit((data) =>
updateMailbox({ ...data, mailDomainSlug: mailDomain.slug }),
)();
};
const steps = [
{
title: t('Update account'),
content: (
<FormProvider {...methods}>
{!!errorCauses.length && <TextErrors causes={errorCauses} />}
<form id={FORM_ID} onSubmit={onSubmitCallback}>
<Box $padding={{ top: 'sm', horizontal: 'md' }} $gap="4px">
<Text $size="md" $weight="bold">
{t('Personal informations')}
</Text>
<Text $theme="greyscale" $variation="600">
{t('Update the user information.')}
</Text>
</Box>
<Box $padding={{ horizontal: 'md' }}>
<Box $margin={{ top: 'base' }}>
<Controller
name="first_name"
control={methods.control}
render={({ field, fieldState }) => (
<Input
{...field}
label={t('First name')}
placeholder={t('First name')}
required
error={fieldState.error?.message}
/>
)}
/>
</Box>
<Box $margin={{ top: 'base' }}>
<Controller
name="last_name"
control={methods.control}
render={({ field, fieldState }) => (
<Input
{...field}
label={t('Last name')}
placeholder={t('Last name')}
required
error={fieldState.error?.message}
/>
)}
/>
</Box>
<Box $margin={{ top: 'base' }}>
<Controller
name="secondary_email"
control={methods.control}
render={({ field, fieldState }) => (
<Input
{...field}
label={t('Personal email address')}
placeholder={t('john.appleseed@free.fr')}
required
error={fieldState.error?.message}
/>
)}
/>
</Box>
</Box>
<HorizontalSeparator $withPadding={true} />
<Box $padding={{ top: 'base', horizontal: 'md' }}>
<Text $size="md" $weight="bold">
{t('Email address')}
</Text>
</Box>
<Box $padding="md">
<Text>
{mailbox.local_part}@{mailDomain.name}
</Text>
</Box>
</form>
</FormProvider>
),
leftAction: (
<Button color="secondary" onClick={onClose}>
{t('Cancel')}
</Button>
),
rightAction: (
<Button
type="submit"
form={FORM_ID}
disabled={!methods.formState.isValid || isPending}
>
{t('Update')}
</Button>
),
},
];
return (
<div id="modal-update-mailbox">
<CustomModal
isOpen={isOpen}
hideCloseButton
step={step}
totalSteps={steps.length}
leftActions={steps[step].leftAction}
rightActions={steps[step].rightAction}
size={ModalSize.MEDIUM}
title={steps[step].title}
onClose={onClose}
closeOnEsc
closeOnClickOutside
>
{steps[step].content}
{isPending && (
<Box $align="center">
<Loader />
</Box>
)}
</CustomModal>
</div>
);
};

View File

@@ -1,3 +1,4 @@
export * from './ModalCreateMailbox';
export * from './ModalUpdateMailbox';
export * from './MailBoxesView';
export * from './panel';

View File

@@ -7,7 +7,7 @@ import { Box, Tag, Text, TextErrors } from '@/components';
import { MailDomain } from '@/features/mail-domains/domains';
import {
MailDomainMailbox,
MailDomainMailboxStatus,
ViewMailbox,
} from '@/features/mail-domains/mailboxes/types';
import { useMailboxesInfinite } from '../../api/useMailboxesInfinite';
@@ -35,13 +35,6 @@ function formatSortModel(sortModel: SortModelItem) {
return sortModel.sort === 'desc' ? `-${sortModel.field}` : sortModel.field;
}
export type ViewMailbox = {
name: string;
id: string;
email: string;
status: MailDomainMailboxStatus;
};
export function MailBoxesListView({
mailDomain,
querySearch,
@@ -76,9 +69,13 @@ export function MailBoxesListView({
}
return data.pages.flatMap((page) =>
page.results.map((mailbox: MailDomainMailbox) => ({
email: `${mailbox.local_part}@${mailDomain.name}`,
name: `${mailbox.first_name} ${mailbox.last_name}`,
id: mailbox.id,
email: `${mailbox.local_part}@${mailDomain.name}`,
first_name: mailbox.first_name,
last_name: mailbox.last_name,
name: `${mailbox.first_name} ${mailbox.last_name}`,
local_part: mailbox.local_part,
secondary_email: mailbox.secondary_email,
status: mailbox.status,
mailbox,
})),
@@ -86,12 +83,15 @@ export function MailBoxesListView({
}, [data, mailDomain]);
const filteredMailboxes = useMemo(() => {
if (!querySearch) {
if (typeof querySearch !== 'string' || !querySearch) {
return mailboxes;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const lowerCaseSearch = querySearch.toLowerCase();
return mailboxes.filter((mailbox) =>
mailbox.email.toLowerCase().includes(lowerCaseSearch),
return mailboxes.filter(
(mailbox) =>
typeof mailbox.email === 'string' &&
mailbox.email.toLowerCase().includes(lowerCaseSearch),
);
}, [querySearch, mailboxes]);
@@ -145,7 +145,7 @@ export function MailBoxesListView({
$theme="greyscale"
$css="text-transform: capitalize;"
>
{row.name}
{`${row.first_name} ${row.last_name}`}
</Text>
),
},

View File

@@ -11,7 +11,10 @@ import { useTranslation } from 'react-i18next';
import { Box, DropButton, IconOptions, Text } from '@/components';
import { MailDomain } from '@/features/mail-domains/domains';
import { ViewMailbox } from '@/features/mail-domains/mailboxes';
import {
ModalUpdateMailbox,
ViewMailbox,
} from '@/features/mail-domains/mailboxes';
import {
useResetPassword,
@@ -28,6 +31,7 @@ export const PanelActions = ({ mailDomain, mailbox }: PanelActionsProps) => {
const [isDropOpen, setIsDropOpen] = useState(false);
const isEnabled = mailbox.status === 'enabled';
const disableModal = useModal();
const updateModal = useModal();
const { toast } = useToastProvider();
const { mutate: updateMailboxStatus } = useUpdateMailboxStatus();
@@ -84,7 +88,7 @@ export const PanelActions = ({ mailDomain, mailbox }: PanelActionsProps) => {
>
<Box>
<Button
aria-label={t('Open the modal to update the role of this access')}
aria-label={t('Open a modal to enable or disable mailbox')}
onClick={() => {
setIsDropOpen(false);
if (isEnabled) {
@@ -93,7 +97,6 @@ export const PanelActions = ({ mailDomain, mailbox }: PanelActionsProps) => {
handleUpdateMailboxStatus();
}
}}
fullWidth
color="primary-text"
icon={
<span className="material-icons" aria-hidden="true">
@@ -106,7 +109,29 @@ export const PanelActions = ({ mailDomain, mailbox }: PanelActionsProps) => {
</Text>
</Button>
<Button
aria-label={t('Open the modal to update the role of this access')}
aria-label={t('Open the modal to update mailbox attributes')}
onClick={() => {
setIsDropOpen(false);
if (isEnabled) {
updateModal.open();
} else {
handleUpdateMailboxStatus();
}
}}
color="primary-text"
disabled={!isEnabled}
icon={
<span className="material-icons" aria-hidden="true">
{isEnabled ? 'settings' : 'block'}
</span>
}
>
<Text $theme={isEnabled ? 'primary' : 'greyscale'}>
{t('Configure mailbox')}
</Text>
</Button>
<Button
aria-label={t('Reset password for this mailbox')}
onClick={() => {
setIsDropOpen(false);
handleResetMailboxPassword();
@@ -120,11 +145,17 @@ export const PanelActions = ({ mailDomain, mailbox }: PanelActionsProps) => {
}
>
<Text $theme={isEnabled ? 'primary' : 'greyscale'}>
{isEnabled ? t('Reset password') : t('Reset password')}
{t('Reset password')}
</Text>
</Button>
</Box>
</DropButton>
<ModalUpdateMailbox
isOpen={updateModal.isOpen}
onClose={updateModal.close}
mailDomain={mailDomain}
mailbox={mailbox}
/>
<Modal
isOpen={disableModal.isOpen}
onClose={disableModal.close}

View File

@@ -14,3 +14,13 @@ export type MailDomainMailboxStatus =
| 'disabled'
| 'pending'
| 'failed';
export interface ViewMailbox {
id: string;
email: string;
first_name: string;
last_name: string;
local_part: string;
secondary_email: string;
status: MailDomainMailboxStatus;
}

View File

@@ -251,7 +251,9 @@ test.describe('Mail domain', () => {
).toBeVisible();
// Click disable in modal
await page.getByRole('button', { name: 'Disable' }).click();
await page
.getByRole('button', { name: 'Disable', exact: true })
.click();
// Verify mailbox status shows as disabled
await expect(