✨(app-desk) create mailbox for a mail domain
Add form to create a mailbox for a mail domain. It sends a http POST request mail-domains/<mail-domain-id>/mailboxes/ on form submit. The form appears inside a modal. Installs react-hook-form, zod, and @hookform/resolvers for form manipulation and field validation.
This commit is contained in:
committed by
Sebastien Nobour
parent
37d32888f5
commit
6981ef17df
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@gouvfr-lasuite/integration": "0.1.3",
|
||||
"@hookform/resolvers": "3.4.2",
|
||||
"@openfun/cunningham-react": "2.9.0",
|
||||
"@tanstack/react-query": "5.36.0",
|
||||
"i18next": "23.11.4",
|
||||
@@ -25,12 +26,15 @@
|
||||
"react": "*",
|
||||
"react-aria-components": "1.2.0",
|
||||
"react-dom": "*",
|
||||
"react-hook-form": "7.51.5",
|
||||
"react-i18next": "14.1.1",
|
||||
"react-select": "5.8.0",
|
||||
"styled-components": "6.1.11",
|
||||
"zod": "3.23.8",
|
||||
"zustand": "4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/devtools": "4.3.1",
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.36.0",
|
||||
"@testing-library/jest-dom": "6.4.5",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { UUID } from 'crypto';
|
||||
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { KEY_LIST_MAILBOX } from './useMailboxes';
|
||||
|
||||
export interface CreateMailboxParams {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
local_part: string;
|
||||
secondary_email: string;
|
||||
phone_number: string;
|
||||
mailDomainId: UUID;
|
||||
}
|
||||
|
||||
export const createMailbox = async ({
|
||||
mailDomainId,
|
||||
...data
|
||||
}: CreateMailboxParams): Promise<void> => {
|
||||
const response = await fetchAPI(`mail-domains/${mailDomainId}/mailboxes/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// TODO: extend errorCauses to return the name of the invalid field names to highlight in the form?
|
||||
throw new APIError(
|
||||
'Failed to create the mailbox',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type UseCreateMailboxParams = { domainId: UUID } & UseMutationOptions<
|
||||
void,
|
||||
APIError,
|
||||
CreateMailboxParams
|
||||
>;
|
||||
|
||||
export function useCreateMailbox(options: UseCreateMailboxParams) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, APIError, CreateMailboxParams>({
|
||||
mutationFn: createMailbox,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_MAILBOX, { id: variables.mailDomainId }],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
options.onSuccess(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (options?.onError) {
|
||||
options.onError(error, variables, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export const getMailDomainMailboxes = async ({
|
||||
return response.json() as Promise<MailDomainMailboxesResponse>;
|
||||
};
|
||||
|
||||
const KEY_LIST_MAILBOX = 'mailboxes';
|
||||
export const KEY_LIST_MAILBOX = 'mailboxes';
|
||||
|
||||
export function useMailDomainMailboxes(
|
||||
param: MailDomainMailboxesParams,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M21.34 26.04C20.9 26.02 20.46 26 20 26C15.16 26 10.64 27.34 6.78 29.64C5.02 30.68 4 32.64 4 34.7V40H22.52C20.94 37.74 20 34.98 20 32C20 29.86 20.5 27.86 21.34 26.04Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M20 24C24.4183 24 28 20.4183 28 16C28 11.5817 24.4183 8 20 8C15.5817 8 12 11.5817 12 16C12 20.4183 15.5817 24 20 24Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M33 24C28.032 24 24 28.032 24 33C24 37.968 28.032 42 33 42C37.968 42 42 37.968 42 33C42 28.032 37.968 24 33 24ZM36.6 33.9H33.9V36.6C33.9 37.095 33.495 37.5 33 37.5C32.505 37.5 32.1 37.095 32.1 36.6V33.9H29.4C28.905 33.9 28.5 33.495 28.5 33C28.5 32.505 28.905 32.1 29.4 32.1H32.1V29.4C32.1 28.905 32.505 28.5 33 28.5C33.495 28.5 33.9 28.905 33.9 29.4V32.1H36.6C37.095 32.1 37.5 32.505 37.5 33C37.5 33.495 37.095 33.9 36.6 33.9Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 925 B |
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Button,
|
||||
DataGrid,
|
||||
Loader,
|
||||
SortModel,
|
||||
@@ -8,11 +9,13 @@ import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Card, Text, TextErrors } from '@/components';
|
||||
import { MailDomain } from '@/features/mail-domains';
|
||||
import { useMailDomainMailboxes } from '@/features/mail-domains/api/useMailDomainMailboxes';
|
||||
import { PAGE_SIZE } from '@/features/mail-domains/conf';
|
||||
|
||||
import { useMailboxes } from '../api/useMailboxes';
|
||||
import { default as MailDomainsLogo } from '../assets/mail-domains-logo.svg';
|
||||
import { PAGE_SIZE } from '../conf';
|
||||
import { MailDomain, MailDomainMailbox } from '../types';
|
||||
|
||||
import { CreateMailboxForm } from './forms/CreateMailboxForm';
|
||||
|
||||
export type ViewMailbox = { email: string; id: string };
|
||||
|
||||
@@ -44,6 +47,9 @@ function formatSortModel(
|
||||
export function MailDomainsContent({ mailDomain }: { mailDomain: MailDomain }) {
|
||||
const [sortModel, setSortModel] = useState<SortModel>([]);
|
||||
const { t } = useTranslation();
|
||||
const [isCreateMailboxFormVisible, setIsCreateMailboxFormVisible] =
|
||||
useState(false);
|
||||
|
||||
const pagination = usePagination({
|
||||
defaultPage: 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
@@ -52,7 +58,7 @@ export function MailDomainsContent({ mailDomain }: { mailDomain: MailDomain }) {
|
||||
const { page, pageSize, setPagesCount } = pagination;
|
||||
const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined;
|
||||
|
||||
const { data, isLoading, error } = useMailDomainMailboxes({
|
||||
const { data, isLoading, error } = useMailboxes({
|
||||
id: mailDomain.id,
|
||||
page,
|
||||
ordering,
|
||||
@@ -60,7 +66,7 @@ export function MailDomainsContent({ mailDomain }: { mailDomain: MailDomain }) {
|
||||
|
||||
const viewMailboxes: ViewMailbox[] =
|
||||
mailDomain && data?.results?.length
|
||||
? data.results.map((mailbox) => ({
|
||||
? data.results.map((mailbox: MailDomainMailbox) => ({
|
||||
email: `${mailbox.local_part}@${mailDomain.name}`,
|
||||
id: mailbox.id,
|
||||
}))
|
||||
@@ -75,7 +81,16 @@ export function MailDomainsContent({ mailDomain }: { mailDomain: MailDomain }) {
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<TopBanner name={mailDomain.name} />
|
||||
{isCreateMailboxFormVisible && mailDomain ? (
|
||||
<CreateMailboxForm
|
||||
mailDomain={mailDomain}
|
||||
setIsFormVisible={setIsCreateMailboxFormVisible}
|
||||
/>
|
||||
) : null}
|
||||
<TopBanner
|
||||
name={mailDomain.name}
|
||||
setIsFormVisible={setIsCreateMailboxFormVisible}
|
||||
/>
|
||||
<Card
|
||||
$padding={{ bottom: 'small' }}
|
||||
$margin={{ all: 'big', top: 'none' }}
|
||||
@@ -104,20 +119,36 @@ export function MailDomainsContent({ mailDomain }: { mailDomain: MailDomain }) {
|
||||
);
|
||||
}
|
||||
|
||||
const TopBanner = ({ name }: { name: string }) => {
|
||||
const TopBanner = ({
|
||||
name,
|
||||
setIsFormVisible,
|
||||
}: {
|
||||
name: string;
|
||||
setIsFormVisible: (value: boolean) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$margin={{ all: 'big', vertical: 'xbig' }}
|
||||
$gap="2.25rem"
|
||||
>
|
||||
<MailDomainsLogo aria-label={t('Mail Domains icon')} />
|
||||
<Text $margin="none" as="h3" $size="h3">
|
||||
{name}
|
||||
</Text>
|
||||
</Box>
|
||||
<>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$margin={{ all: 'big', vertical: 'xbig' }}
|
||||
$gap="2.25rem"
|
||||
>
|
||||
<MailDomainsLogo aria-label={t('Mail Domains icon')} />
|
||||
<Text $margin="none" as="h3" $size="h3">
|
||||
{name}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box $margin={{ all: 'big', bottom: 'small' }} $align="flex-end">
|
||||
<Button
|
||||
aria-label={t(`Create a mailbox in {{name}} domain`, { name })}
|
||||
onClick={() => setIsFormVisible(true)}
|
||||
>
|
||||
{t('Create a mailbox')}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalSize,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import React from 'react';
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
UseFormReturn,
|
||||
useForm,
|
||||
} from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Box, Text, TextErrors } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { CreateMailboxParams, useCreateMailbox } from '../../api';
|
||||
import IconCreateMailbox from '../../assets/create-mailbox.svg';
|
||||
import { MailDomain } from '../../types';
|
||||
|
||||
const FORM_ID: string = 'form-create-mailbox';
|
||||
|
||||
const createMailboxValidationSchema = z.object({
|
||||
first_name: z.string().min(1),
|
||||
last_name: z.string().min(1),
|
||||
local_part: z.string().min(1),
|
||||
secondary_email: z.string().min(1),
|
||||
phone_number: z.string().min(1),
|
||||
});
|
||||
|
||||
export const CreateMailboxForm = ({
|
||||
mailDomain,
|
||||
setIsFormVisible,
|
||||
}: {
|
||||
mailDomain: MailDomain;
|
||||
setIsFormVisible: (value: boolean) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToastProvider();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
const methods = useForm<CreateMailboxParams>({
|
||||
delayError: 0,
|
||||
defaultValues: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
local_part: '',
|
||||
secondary_email: '',
|
||||
phone_number: '',
|
||||
},
|
||||
mode: 'onChange',
|
||||
reValidateMode: 'onChange',
|
||||
resolver: zodResolver(createMailboxValidationSchema),
|
||||
});
|
||||
|
||||
const { mutate: createMailbox, ...queryState } = useCreateMailbox({
|
||||
domainId: mailDomain.id,
|
||||
onSuccess: () => {
|
||||
toast(t('Mailbox created!'), VariantType.SUCCESS, {
|
||||
duration: 4000,
|
||||
});
|
||||
|
||||
setIsFormVisible(false);
|
||||
},
|
||||
});
|
||||
|
||||
const closeModal = () => setIsFormVisible(false);
|
||||
|
||||
const onSubmitCallback = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
void methods.handleSubmit((data) =>
|
||||
createMailbox({ ...data, mailDomainId: mailDomain.id }),
|
||||
)();
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Modal
|
||||
isOpen
|
||||
leftActions={
|
||||
<Button
|
||||
color="secondary"
|
||||
fullWidth
|
||||
onClick={closeModal}
|
||||
disabled={methods.formState.isSubmitting}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
}
|
||||
onClose={closeModal}
|
||||
closeOnClickOutside
|
||||
hideCloseButton
|
||||
rightActions={
|
||||
<Button
|
||||
color="primary"
|
||||
fullWidth
|
||||
type="submit"
|
||||
form={FORM_ID}
|
||||
disabled={methods.formState.isSubmitting}
|
||||
>
|
||||
{t('Submit')}
|
||||
</Button>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<Box $align="center" $gap="1rem">
|
||||
<IconCreateMailbox
|
||||
width={48}
|
||||
color={colorsTokens()['primary-text']}
|
||||
title={t('Mailbox creation form')}
|
||||
/>
|
||||
<Text $size="h3" $margin="none" role="heading" aria-level={3}>
|
||||
{t('Create a mailbox')}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box $width="100%" $margin={{ top: 'large', bottom: 'xl' }}>
|
||||
{queryState.isError && (
|
||||
<TextErrors className="mb-s" causes={queryState.error.cause} />
|
||||
)}
|
||||
{methods ? (
|
||||
<Form
|
||||
methods={methods}
|
||||
mailDomain={mailDomain}
|
||||
onSubmitCallback={onSubmitCallback}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
</Modal>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const Form = ({
|
||||
methods,
|
||||
mailDomain,
|
||||
onSubmitCallback,
|
||||
}: {
|
||||
methods: UseFormReturn<CreateMailboxParams>;
|
||||
mailDomain: MailDomain;
|
||||
onSubmitCallback: (event: React.FormEvent) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmitCallback} id={FORM_ID}>
|
||||
<Box $direction="column" $width="100%" $gap="2rem" $margin="auto">
|
||||
<Box $margin={{ horizontal: 'none' }}>
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="first_name"
|
||||
render={({ fieldState }) => (
|
||||
<Input
|
||||
aria-invalid={!!fieldState.error}
|
||||
label={t('First name')}
|
||||
state={fieldState.error ? 'error' : 'default'}
|
||||
text={
|
||||
fieldState.error
|
||||
? t('Please enter your first name')
|
||||
: undefined
|
||||
}
|
||||
{...methods.register('first_name')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box $margin={{ horizontal: 'none' }}>
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="last_name"
|
||||
render={({ fieldState }) => (
|
||||
<Input
|
||||
aria-invalid={!!fieldState.error}
|
||||
label={t('Last name')}
|
||||
state={fieldState.error ? 'error' : 'default'}
|
||||
text={
|
||||
fieldState.error
|
||||
? t('Please enter your last name')
|
||||
: undefined
|
||||
}
|
||||
{...methods.register('last_name')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box $margin={{ horizontal: 'none' }} $direction="row">
|
||||
<Box $width="65%">
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="local_part"
|
||||
render={({ fieldState }) => (
|
||||
<Input
|
||||
aria-invalid={!!fieldState.error}
|
||||
label={t('Main email address')}
|
||||
state={fieldState.error ? 'error' : 'default'}
|
||||
text={
|
||||
fieldState.error
|
||||
? t(
|
||||
'Please enter the first part of the email address, without including "@" in it',
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
{...methods.register('local_part')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
as="span"
|
||||
$theme="primary"
|
||||
$size="1rem"
|
||||
$display="inline-block"
|
||||
$css={`
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
left: 1rem;
|
||||
top: 1rem;
|
||||
`}
|
||||
>
|
||||
@{mailDomain.name}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box $margin={{ horizontal: 'none' }}>
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="secondary_email"
|
||||
render={({ fieldState }) => (
|
||||
<Input
|
||||
aria-invalid={!!fieldState.error}
|
||||
label={t('Secondary email address')}
|
||||
state={fieldState.error ? 'error' : 'default'}
|
||||
text={
|
||||
fieldState.error
|
||||
? t('Please enter your secondary email address')
|
||||
: undefined
|
||||
}
|
||||
{...methods.register('secondary_email')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box $margin={{ horizontal: 'none' }}>
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="phone_number"
|
||||
render={({ fieldState }) => (
|
||||
<Input
|
||||
aria-invalid={!!fieldState.error}
|
||||
label={t('Phone number')}
|
||||
state={fieldState.error ? 'error' : 'default'}
|
||||
text={
|
||||
fieldState.error
|
||||
? t('Please enter your phone number')
|
||||
: undefined
|
||||
}
|
||||
{...methods.register('phone_number')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './components/';
|
||||
export * from './components';
|
||||
export * from './types';
|
||||
export * from './api';
|
||||
export * from './store';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useMailDomainsStore';
|
||||
Reference in New Issue
Block a user