✨(frontend) display button to re-run fetch domain from dimail
Add the button in the modal which describes actions required to make the domain work
This commit is contained in:
@@ -10,6 +10,7 @@ and this project adheres to
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- ✨(domains) allow user to re-run all fetch domain data from dimail
|
||||||
- ✨(domains) display DNS config expected for domain with required actions
|
- ✨(domains) display DNS config expected for domain with required actions
|
||||||
- ✨(domains) check status after creation
|
- ✨(domains) check status after creation
|
||||||
- ✨(domains) display required actions to do on domain
|
- ✨(domains) display required actions to do on domain
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { VariantType } from '@openfun/cunningham-react';
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import fetchMock from 'fetch-mock';
|
import fetchMock from 'fetch-mock';
|
||||||
|
|
||||||
import { MailDomain } from '@/features/mail-domains/domains';
|
import { MailDomain } from '@/features/mail-domains/domains';
|
||||||
@@ -6,14 +7,6 @@ import { AppWrapper } from '@/tests/utils';
|
|||||||
|
|
||||||
import { MailDomainView } from '../components/MailDomainView';
|
import { MailDomainView } from '../components/MailDomainView';
|
||||||
|
|
||||||
const toast = jest.fn();
|
|
||||||
jest.mock('@openfun/cunningham-react', () => ({
|
|
||||||
...jest.requireActual('@openfun/cunningham-react'),
|
|
||||||
useToastProvider: () => ({
|
|
||||||
toast,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockMailDomain: MailDomain = {
|
const mockMailDomain: MailDomain = {
|
||||||
id: '123e4567-e89b-12d3-a456-426614174000',
|
id: '123e4567-e89b-12d3-a456-426614174000',
|
||||||
name: 'example.com',
|
name: 'example.com',
|
||||||
@@ -56,7 +49,17 @@ const mockMailDomain: MailDomain = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toast = jest.fn();
|
||||||
|
jest.mock('@openfun/cunningham-react', () => ({
|
||||||
|
...jest.requireActual('@openfun/cunningham-react'),
|
||||||
|
useToastProvider: () => ({
|
||||||
|
toast,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('<MailDomainView />', () => {
|
describe('<MailDomainView />', () => {
|
||||||
|
const apiUrl = `end:/mail-domains/${mockMailDomain.slug}/fetch/`;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
@@ -64,6 +67,7 @@ describe('<MailDomainView />', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
fetchMock.restore();
|
fetchMock.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('display action required button and open modal with information when domain status is action_required', () => {
|
it('display action required button and open modal with information when domain status is action_required', () => {
|
||||||
render(<MailDomainView mailDomain={mockMailDomain} />, {
|
render(<MailDomainView mailDomain={mockMailDomain} />, {
|
||||||
wrapper: AppWrapper,
|
wrapper: AppWrapper,
|
||||||
@@ -79,7 +83,7 @@ describe('<MailDomainView />', () => {
|
|||||||
expect(screen.getByText('Required actions on domain')).toBeInTheDocument();
|
expect(screen.getByText('Required actions on domain')).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
screen.getByText(
|
||||||
/Je veux que le MX du domaine soit mx.ox.numerique.gouv.fr/,
|
/Je veux que le MX du domaine soit mx.ox.numerique.gouv.fr./i,
|
||||||
),
|
),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
@@ -92,4 +96,45 @@ describe('<MailDomainView />', () => {
|
|||||||
screen.getByText(/webmail.ox.numerique.gouv.fr./i),
|
screen.getByText(/webmail.ox.numerique.gouv.fr./i),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
it('allows re-running domain check when clicking re-run button', async () => {
|
||||||
|
// Mock the fetch call
|
||||||
|
fetchMock.postOnce(apiUrl, {
|
||||||
|
status: 200,
|
||||||
|
body: mockMailDomain,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MailDomainView
|
||||||
|
mailDomain={mockMailDomain}
|
||||||
|
onMailDomainUpdate={jest.fn()}
|
||||||
|
/>,
|
||||||
|
{ wrapper: AppWrapper },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if action required button is displayed
|
||||||
|
const actionButton = screen.getByText('Actions required');
|
||||||
|
expect(actionButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click the button and verify modal content
|
||||||
|
fireEvent.click(actionButton);
|
||||||
|
|
||||||
|
// Verify modal title and content
|
||||||
|
expect(screen.getByText('Required actions on domain')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
/Je veux que le MX du domaine soit mx.ox.numerique.gouv.fr./,
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Find and click re-run button
|
||||||
|
const reRunButton = screen.getByText('Re-run check');
|
||||||
|
fireEvent.click(reRunButton);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock.called(apiUrl)).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(toast).toHaveBeenCalledWith(
|
||||||
|
'Domain data fetched successfully',
|
||||||
|
VariantType.SUCCESS,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import fetchMock from 'fetch-mock';
|
||||||
|
|
||||||
|
import { APIError } from '@/api';
|
||||||
|
import { fetchMailDomain } from '@/features/mail-domains/domains/api/useFetchMailDomain';
|
||||||
|
import { MailDomain } from '@/features/mail-domains/domains/types';
|
||||||
|
|
||||||
|
const mockMailDomain: MailDomain = {
|
||||||
|
id: '123e4567-e89b-12d3-a456-426614174000',
|
||||||
|
name: 'example.com',
|
||||||
|
status: 'enabled',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
slug: 'example-com',
|
||||||
|
support_email: 'support@example.com',
|
||||||
|
abilities: {
|
||||||
|
delete: false,
|
||||||
|
manage_accesses: true,
|
||||||
|
get: true,
|
||||||
|
patch: true,
|
||||||
|
put: true,
|
||||||
|
post: true,
|
||||||
|
},
|
||||||
|
action_required_details: {
|
||||||
|
mx: 'Je veux que le MX du domaine soit mx.ox.numerique.gouv.fr.',
|
||||||
|
},
|
||||||
|
expected_config: [
|
||||||
|
{ target: '', type: 'mx', value: 'mx.ox.numerique.gouv.fr.' },
|
||||||
|
{
|
||||||
|
target: 'dimail._domainkey',
|
||||||
|
type: 'txt',
|
||||||
|
value: 'v=DKIM1; h=sha256; k=rsa; p=X...X',
|
||||||
|
},
|
||||||
|
{ target: 'imap', type: 'cname', value: 'imap.ox.numerique.gouv.fr.' },
|
||||||
|
{ target: 'smtp', type: 'cname', value: 'smtp.ox.numerique.gouv.fr.' },
|
||||||
|
{
|
||||||
|
target: '',
|
||||||
|
type: 'txt',
|
||||||
|
value: 'v=spf1 include:_spf.ox.numerique.gouv.fr -all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: 'webmail',
|
||||||
|
type: 'cname',
|
||||||
|
value: 'webmail.ox.numerique.gouv.fr.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('fetchMailDomain', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
fetchMock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetch the domain successfully', async () => {
|
||||||
|
fetchMock.postOnce('end:/mail-domains/example-slug/fetch/', {
|
||||||
|
status: 200,
|
||||||
|
body: mockMailDomain,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await fetchMailDomain('example-slug');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockMailDomain);
|
||||||
|
expect(fetchMock.calls()).toHaveLength(1);
|
||||||
|
expect(fetchMock.lastUrl()).toContain('/mail-domains/example-slug/fetch/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throw an error when the domain is not found', async () => {
|
||||||
|
fetchMock.postOnce('end:/mail-domains/example-slug/fetch/', {
|
||||||
|
status: 404,
|
||||||
|
body: { cause: ['Domain not found'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fetchMailDomain('example-slug')).rejects.toThrow(APIError);
|
||||||
|
expect(fetchMock.calls()).toHaveLength(1);
|
||||||
|
expect(fetchMock.lastUrl()).toContain('/mail-domains/example-slug/fetch/');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||||
|
|
||||||
|
import { MailDomain } from '../types';
|
||||||
|
|
||||||
|
export const fetchMailDomain = async (slug: string): Promise<MailDomain> => {
|
||||||
|
const response = await fetchAPI(`mail-domains/${slug}/fetch/`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new APIError(
|
||||||
|
'Failed to fetch domain from Dimail',
|
||||||
|
await errorCauses(response),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<MailDomain>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFetchFromDimail = ({
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
onSuccess: (data: MailDomain) => void;
|
||||||
|
onError: (error: APIError) => void;
|
||||||
|
}) => {
|
||||||
|
return useMutation<MailDomain, APIError, string>({
|
||||||
|
mutationFn: fetchMailDomain,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
onSuccess(data);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
onError(error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
Button,
|
||||||
Modal,
|
Modal,
|
||||||
ModalSize,
|
ModalSize,
|
||||||
VariantType,
|
VariantType,
|
||||||
@@ -15,10 +16,14 @@ import MailDomainsLogo from '@/features/mail-domains/assets/mail-domains-logo.sv
|
|||||||
import { MailDomain, Role } from '@/features/mail-domains/domains';
|
import { MailDomain, Role } from '@/features/mail-domains/domains';
|
||||||
import { MailDomainsContent } from '@/features/mail-domains/mailboxes';
|
import { MailDomainsContent } from '@/features/mail-domains/mailboxes';
|
||||||
|
|
||||||
|
import { useFetchFromDimail } from '../api/useFetchMailDomain';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mailDomain: MailDomain;
|
mailDomain: MailDomain;
|
||||||
|
onMailDomainUpdate?: (updatedDomain: MailDomain) => void;
|
||||||
};
|
};
|
||||||
export const MailDomainView = ({ mailDomain }: Props) => {
|
|
||||||
|
export const MailDomainView = ({ mailDomain, onMailDomainUpdate }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { toast } = useToastProvider();
|
const { toast } = useToastProvider();
|
||||||
const [showModal, setShowModal] = React.useState(false);
|
const [showModal, setShowModal] = React.useState(false);
|
||||||
@@ -57,6 +62,19 @@ export const MailDomainView = ({ mailDomain }: Props) => {
|
|||||||
toast(t('copy done'), VariantType.SUCCESS);
|
toast(t('copy done'), VariantType.SUCCESS);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { mutate: fetchMailDomain } = useFetchFromDimail({
|
||||||
|
onSuccess: (data: MailDomain) => {
|
||||||
|
console.info('fetchMailDomain success', data);
|
||||||
|
setShowModal(false);
|
||||||
|
toast(t('Domain data fetched successfully'), VariantType.SUCCESS);
|
||||||
|
onMailDomainUpdate?.(data);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
console.error('fetchMailDomain error');
|
||||||
|
toast(t('Failed to fetch domain data'), VariantType.ERROR);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showModal && (
|
{showModal && (
|
||||||
@@ -135,6 +153,17 @@ export const MailDomainView = ({ mailDomain }: Props) => {
|
|||||||
</pre>
|
</pre>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
<pre>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
void fetchMailDomain(mailDomain.slug);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Re-run check')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</pre>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
<Box $padding="big">
|
<Box $padding="big">
|
||||||
@@ -155,6 +184,25 @@ export const MailDomainView = ({ mailDomain }: Props) => {
|
|||||||
<Text $margin="none" as="h3" $size="h3">
|
<Text $margin="none" as="h3" $size="h3">
|
||||||
{mailDomain?.name}
|
{mailDomain?.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
{/* TODO: remove when pending status will be removed */}
|
||||||
|
{mailDomain?.status === 'pending' && (
|
||||||
|
<button
|
||||||
|
onClick={handleShowModal}
|
||||||
|
style={{
|
||||||
|
padding: '5px 10px',
|
||||||
|
marginLeft: '10px',
|
||||||
|
backgroundColor: '#cccccc',
|
||||||
|
border: 'none',
|
||||||
|
color: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: '500',
|
||||||
|
borderRadius: '5px',
|
||||||
|
}}
|
||||||
|
data-modal="mail-domain-status"
|
||||||
|
>
|
||||||
|
{t('Pending')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{mailDomain?.status === 'action_required' && (
|
{mailDomain?.status === 'action_required' && (
|
||||||
<button
|
<button
|
||||||
onClick={handleShowModal}
|
onClick={handleShowModal}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
"Deleting the {{teamName}} team": "Suppression du groupe {{teamName}}",
|
"Deleting the {{teamName}} team": "Suppression du groupe {{teamName}}",
|
||||||
"Disable": "Désactiver",
|
"Disable": "Désactiver",
|
||||||
"Disable mailbox": "Désactiver la boîte mail",
|
"Disable mailbox": "Désactiver la boîte mail",
|
||||||
|
"Domain data fetched successfully": "Les données de domaine ont été récupérées avec succès",
|
||||||
"Domain name": "Nom de domaine",
|
"Domain name": "Nom de domaine",
|
||||||
"E-mail:": "E-mail:",
|
"E-mail:": "E-mail:",
|
||||||
"E.g. : jean.dupont@mail.fr": "Ex. : jean.dupont@mail.fr",
|
"E.g. : jean.dupont@mail.fr": "Ex. : jean.dupont@mail.fr",
|
||||||
@@ -86,6 +87,7 @@
|
|||||||
"Example: saint-laurent.fr": "Exemple : saint-laurent.fr",
|
"Example: saint-laurent.fr": "Exemple : saint-laurent.fr",
|
||||||
"Failed to add {{name}} in the team": "Impossible d'ajouter {{name}} au groupe",
|
"Failed to add {{name}} in the team": "Impossible d'ajouter {{name}} au groupe",
|
||||||
"Failed to create the invitation for {{email}}": "Impossible de créer l'invitation pour {{email}}",
|
"Failed to create the invitation for {{email}}": "Impossible de créer l'invitation pour {{email}}",
|
||||||
|
"Failed to fetch domain data": "Impossible de récupérer les données du domaine",
|
||||||
"Failed to update mailbox status": "Impossible de mettre à jour le statut de la boîte mail",
|
"Failed to update mailbox status": "Impossible de mettre à jour le statut de la boîte mail",
|
||||||
"Filter member list": "Filtrer la liste des membres",
|
"Filter member list": "Filtrer la liste des membres",
|
||||||
"Find a member to add to the team": "Trouver un membre à ajouter au groupe",
|
"Find a member to add to the team": "Trouver un membre à ajouter au groupe",
|
||||||
@@ -145,6 +147,7 @@
|
|||||||
"Open the teams panel": "Ouvrir le panneau des groupes",
|
"Open the teams panel": "Ouvrir le panneau des groupes",
|
||||||
"Ouch!": "Aïe !",
|
"Ouch!": "Aïe !",
|
||||||
"Owner": "Propriétaire",
|
"Owner": "Propriétaire",
|
||||||
|
"Pending": "En attente",
|
||||||
"Personal data and cookies": "Données personnelles et cookies",
|
"Personal data and cookies": "Données personnelles et cookies",
|
||||||
"Please enter a valid email address": "Merci de saisir une adresse e-mail valide",
|
"Please enter a valid email address": "Merci de saisir une adresse e-mail valide",
|
||||||
"Please enter a valid email address.\nE.g. : jean.dupont@mail.fr": "Veuillez entrer une adresse e-mail valide.\nEx. : jean.dupont@mail.fr",
|
"Please enter a valid email address.\nE.g. : jean.dupont@mail.fr": "Veuillez entrer une adresse e-mail valide.\nEx. : jean.dupont@mail.fr",
|
||||||
@@ -153,6 +156,7 @@
|
|||||||
"Publication Director": "Directeur de la publication",
|
"Publication Director": "Directeur de la publication",
|
||||||
"Publisher": "Éditeur",
|
"Publisher": "Éditeur",
|
||||||
"Radio buttons to update the roles": "Boutons radio pour mettre à jour les rôles",
|
"Radio buttons to update the roles": "Boutons radio pour mettre à jour les rôles",
|
||||||
|
"Re-run check": "Ré-exécuter la vérification",
|
||||||
"Remedy": "Voie de recours",
|
"Remedy": "Voie de recours",
|
||||||
"Remove from domain": "Retirer du domaine",
|
"Remove from domain": "Retirer du domaine",
|
||||||
"Remove from group": "Retirer du groupe",
|
"Remove from group": "Retirer du groupe",
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Loader } from '@openfun/cunningham-react';
|
import { Loader } from '@openfun/cunningham-react';
|
||||||
import { useRouter as useNavigate } from 'next/navigation';
|
import { useRouter as useNavigate } from 'next/navigation';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import React, { ReactElement } from 'react';
|
import React, { ReactElement, useState } from 'react';
|
||||||
|
|
||||||
import { Box } from '@/components';
|
import { Box } from '@/components';
|
||||||
import { TextErrors } from '@/components/TextErrors';
|
import { TextErrors } from '@/components/TextErrors';
|
||||||
import {
|
import {
|
||||||
|
MailDomain,
|
||||||
MailDomainsLayout,
|
MailDomainsLayout,
|
||||||
useMailDomain,
|
useMailDomain,
|
||||||
} from '@/features/mail-domains/domains';
|
} from '@/features/mail-domains/domains';
|
||||||
@@ -14,13 +15,15 @@ import { NextPageWithLayout } from '@/types/next';
|
|||||||
|
|
||||||
const MailboxesPage: NextPageWithLayout = () => {
|
const MailboxesPage: NextPageWithLayout = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [currentMailDomain, setCurrentMailDomain] = useState<MailDomain | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
if (router?.query?.slug && typeof router.query.slug !== 'string') {
|
if (router?.query?.slug && typeof router.query.slug !== 'string') {
|
||||||
throw new Error('Invalid mail domain slug');
|
throw new Error('Invalid mail domain slug');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { slug } = router.query;
|
const { slug } = router.query;
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -30,6 +33,13 @@ const MailboxesPage: NextPageWithLayout = () => {
|
|||||||
isLoading,
|
isLoading,
|
||||||
} = useMailDomain({ slug: String(slug) });
|
} = useMailDomain({ slug: String(slug) });
|
||||||
|
|
||||||
|
// Update currentMailDomain when mailDomain changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (mailDomain) {
|
||||||
|
setCurrentMailDomain(mailDomain);
|
||||||
|
}
|
||||||
|
}, [mailDomain]);
|
||||||
|
|
||||||
if (error?.status === 404) {
|
if (error?.status === 404) {
|
||||||
navigate.replace(`/404`);
|
navigate.replace(`/404`);
|
||||||
return null;
|
return null;
|
||||||
@@ -47,11 +57,16 @@ const MailboxesPage: NextPageWithLayout = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mailDomain) {
|
if (!currentMailDomain) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <MailDomainView mailDomain={mailDomain} />;
|
return (
|
||||||
|
<MailDomainView
|
||||||
|
mailDomain={currentMailDomain}
|
||||||
|
onMailDomainUpdate={setCurrentMailDomain}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
MailboxesPage.getLayout = function getLayout(page: ReactElement) {
|
MailboxesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
|||||||
Reference in New Issue
Block a user