♻️(frontend) improve the ui error and message info ui
Improve the ui error and message info ui: - Can use a icon in TextErrors component - use mode the Alert component to display message info
This commit is contained in:
@@ -29,6 +29,7 @@ and this project adheres to
|
|||||||
- (frontend) pdf has title doc (#84)
|
- (frontend) pdf has title doc (#84)
|
||||||
- ⚡️(e2e) unique login between tests (#80)
|
- ⚡️(e2e) unique login between tests (#80)
|
||||||
- ⚡️(CI) improve e2e job (#86)
|
- ⚡️(CI) improve e2e job (#86)
|
||||||
|
- ♻️(frontend) improve the error and message info ui (#93)
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Alert, VariantType } from '@openfun/cunningham-react';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Box, Text, TextType } from '@/components';
|
import { Box, Text, TextType } from '@/components';
|
||||||
@@ -5,40 +7,38 @@ import { Box, Text, TextType } from '@/components';
|
|||||||
interface TextErrorsProps extends TextType {
|
interface TextErrorsProps extends TextType {
|
||||||
causes?: string[];
|
causes?: string[];
|
||||||
defaultMessage?: string;
|
defaultMessage?: string;
|
||||||
|
icon?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TextErrors = ({
|
export const TextErrors = ({
|
||||||
causes,
|
causes,
|
||||||
defaultMessage,
|
defaultMessage,
|
||||||
|
icon,
|
||||||
...textProps
|
...textProps
|
||||||
}: TextErrorsProps) => {
|
}: TextErrorsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Alert canClose={false} type={VariantType.ERROR} icon={icon}>
|
||||||
{causes &&
|
<Box $direction="column" $gap="0.2rem">
|
||||||
causes.map((cause, i) => (
|
{causes &&
|
||||||
<Text
|
causes.map((cause, i) => (
|
||||||
key={`causes-${i}`}
|
<Text
|
||||||
$margin={{ top: 'small' }}
|
key={`causes-${i}`}
|
||||||
$theme="danger"
|
$theme="danger"
|
||||||
$textAlign="center"
|
$textAlign="center"
|
||||||
{...textProps}
|
{...textProps}
|
||||||
>
|
>
|
||||||
{cause}
|
{cause}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{!causes && (
|
{!causes && (
|
||||||
<Text
|
<Text $theme="danger" $textAlign="center" {...textProps}>
|
||||||
$margin={{ top: 'small' }}
|
{defaultMessage || t('Something bad happens, please retry.')}
|
||||||
$theme="danger"
|
</Text>
|
||||||
$textAlign="center"
|
)}
|
||||||
{...textProps}
|
</Box>
|
||||||
>
|
</Alert>
|
||||||
{defaultMessage || t('Something bad happens, please retry.')}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -172,13 +172,9 @@ describe('ModalRole', () => {
|
|||||||
{ wrapper: AppWrapper },
|
{ wrapper: AppWrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
|
||||||
screen.getByText('You are the sole owner of this group.'),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
screen.getByText(
|
||||||
'Make another member the group owner, before you can change your own role.',
|
'You are the sole owner of this group, make another member the group owner, before you can change your own role.',
|
||||||
),
|
),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export const MemberAction = ({
|
|||||||
}}
|
}}
|
||||||
color="primary-text"
|
color="primary-text"
|
||||||
icon={<span className="material-icons">edit</span>}
|
icon={<span className="material-icons">edit</span>}
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
<Text $theme="primary">{t('Update role')}</Text>
|
<Text $theme="primary">{t('Update role')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -64,6 +65,7 @@ export const MemberAction = ({
|
|||||||
}}
|
}}
|
||||||
color="primary-text"
|
color="primary-text"
|
||||||
icon={<span className="material-icons">delete</span>}
|
icon={<span className="material-icons">delete</span>}
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
{t('Remove from group')}
|
{t('Remove from group')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Modal,
|
Modal,
|
||||||
ModalSize,
|
ModalSize,
|
||||||
VariantType,
|
VariantType,
|
||||||
useToastProvider,
|
useToastProvider,
|
||||||
} from '@openfun/cunningham-react';
|
} from '@openfun/cunningham-react';
|
||||||
import { t } from 'i18next';
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import IconUser from '@/assets/icons/icon-user.svg';
|
import IconUser from '@/assets/icons/icon-user.svg';
|
||||||
import { Box, Text, TextErrors } from '@/components';
|
import { Box, Text, TextErrors } from '@/components';
|
||||||
@@ -28,6 +29,7 @@ export const ModalDelete = ({ access, onClose, doc }: ModalDeleteProps) => {
|
|||||||
const { toast } = useToastProvider();
|
const { toast } = useToastProvider();
|
||||||
const { colorsTokens } = useCunninghamTheme();
|
const { colorsTokens } = useCunninghamTheme();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { isMyself, isLastOwner, isOtherOwner } = useWhoAmI(access);
|
const { isMyself, isLastOwner, isOtherOwner } = useWhoAmI(access);
|
||||||
const isNotAllowed = isOtherOwner || isLastOwner;
|
const isNotAllowed = isOtherOwner || isLastOwner;
|
||||||
@@ -89,33 +91,28 @@ export const ModalDelete = ({ access, onClose, doc }: ModalDeleteProps) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Box aria-label={t('Radio buttons to update the roles')}>
|
<Box aria-label={t('Radio buttons to update the roles')}>
|
||||||
<Text>
|
{!isLastOwner && !isOtherOwner && !isErrorUpdate && (
|
||||||
{t('Are you sure you want to remove this member from the document?')}
|
<Alert canClose={false} type={VariantType.INFO}>
|
||||||
</Text>
|
<Text>
|
||||||
|
{t(
|
||||||
{isErrorUpdate && (
|
'Are you sure you want to remove this member from the document?',
|
||||||
<TextErrors
|
)}
|
||||||
$margin={{ bottom: 'small' }}
|
</Text>
|
||||||
causes={errorUpdate.cause}
|
</Alert>
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(isLastOwner || isOtherOwner) && (
|
{isErrorUpdate && <TextErrors causes={errorUpdate.cause} />}
|
||||||
<Text
|
|
||||||
$theme="warning"
|
{(isLastOwner || isOtherOwner) && !isErrorUpdate && (
|
||||||
$direction="row"
|
<Alert canClose={false} type={VariantType.WARNING}>
|
||||||
$align="center"
|
<Text>
|
||||||
$gap="0.5rem"
|
{isLastOwner &&
|
||||||
$margin="tiny"
|
t(
|
||||||
$justify="center"
|
'You are the last owner, you cannot be removed from your document.',
|
||||||
>
|
)}
|
||||||
<span className="material-icons">warning</span>
|
{isOtherOwner && t('You cannot remove other owner.')}
|
||||||
{isLastOwner &&
|
</Text>
|
||||||
t(
|
</Alert>
|
||||||
'You are the last owner, you cannot be removed from your document.',
|
|
||||||
)}
|
|
||||||
{isOtherOwner && t('You cannot remove other owner.')}
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Modal,
|
Modal,
|
||||||
ModalSize,
|
ModalSize,
|
||||||
@@ -84,38 +85,36 @@ export const ModalRole = ({
|
|||||||
title={t('Update the role')}
|
title={t('Update the role')}
|
||||||
>
|
>
|
||||||
<Box aria-label={t('Radio buttons to update the roles')}>
|
<Box aria-label={t('Radio buttons to update the roles')}>
|
||||||
{isErrorUpdate && (
|
{isErrorUpdate && <TextErrors causes={errorUpdate.cause} />}
|
||||||
<TextErrors
|
|
||||||
$margin={{ bottom: 'small' }}
|
|
||||||
causes={errorUpdate.cause}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(isLastOwner || isOtherOwner) && (
|
{(isLastOwner || isOtherOwner) && (
|
||||||
<Text
|
<Box
|
||||||
$theme="warning"
|
|
||||||
$direction="row"
|
$direction="row"
|
||||||
$align="center"
|
$align="center"
|
||||||
$gap="0.5rem"
|
$gap="0.5rem"
|
||||||
$margin={{ bottom: 'tiny', top: 'none' }}
|
$margin={{ bottom: 'tiny', top: 'none' }}
|
||||||
as="div"
|
|
||||||
>
|
>
|
||||||
<span className="material-icons">warning</span>
|
<Alert
|
||||||
{isLastOwner && (
|
canClose={false}
|
||||||
<Box $align="flex-start">
|
type={VariantType.WARNING}
|
||||||
<Text $theme="warning">
|
icon={
|
||||||
{t('You are the sole owner of this group.')}
|
<Text className="material-icons" $theme="warning">
|
||||||
|
warning
|
||||||
</Text>
|
</Text>
|
||||||
<Text $theme="warning">
|
}
|
||||||
{t(
|
>
|
||||||
'Make another member the group owner, before you can change your own role.',
|
{isLastOwner && (
|
||||||
)}
|
<Box $direction="column" $gap="0.2rem">
|
||||||
</Text>
|
<Text $theme="warning">
|
||||||
</Box>
|
{t(
|
||||||
)}
|
'You are the sole owner of this group, make another member the group owner, before you can change your own role.',
|
||||||
|
)}
|
||||||
{isOtherOwner && t('You cannot update the role of other owner.')}
|
</Text>
|
||||||
</Text>
|
</Box>
|
||||||
|
)}
|
||||||
|
{isOtherOwner && t('You cannot update the role of other owner.')}
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ChooseRole
|
<ChooseRole
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Modal,
|
Modal,
|
||||||
ModalSize,
|
ModalSize,
|
||||||
@@ -83,16 +84,18 @@ export const ModalRemovePad = ({ onClose, pad }: ModalRemovePadProps) => {
|
|||||||
$margin={{ bottom: 'xl' }}
|
$margin={{ bottom: 'xl' }}
|
||||||
aria-label={t('Content modal to delete document')}
|
aria-label={t('Content modal to delete document')}
|
||||||
>
|
>
|
||||||
<Text as="p" $margin={{ bottom: 'big' }}>
|
{!isError && (
|
||||||
{t('Are you sure you want to delete the document "{{title}}"?', {
|
<Alert canClose={false} type={VariantType.WARNING}>
|
||||||
title: pad.title,
|
<Text>
|
||||||
})}
|
{t('Are you sure you want to delete the document "{{title}}"?', {
|
||||||
</Text>
|
title: pad.title,
|
||||||
|
})}
|
||||||
{isError && (
|
</Text>
|
||||||
<TextErrors $margin={{ bottom: 'small' }} causes={error.cause} />
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isError && <TextErrors causes={error.cause} />}
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
as="p"
|
as="p"
|
||||||
$padding="small"
|
$padding="small"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Modal,
|
Modal,
|
||||||
ModalSize,
|
ModalSize,
|
||||||
@@ -6,8 +7,8 @@ import {
|
|||||||
VariantType,
|
VariantType,
|
||||||
useToastProvider,
|
useToastProvider,
|
||||||
} from '@openfun/cunningham-react';
|
} from '@openfun/cunningham-react';
|
||||||
import { t } from 'i18next';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Box, Text } from '@/components';
|
import { Box, Text } from '@/components';
|
||||||
import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
|
import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
|
||||||
@@ -29,6 +30,7 @@ export const ModalUpdatePad = ({ onClose, pad }: ModalUpdatePadProps) => {
|
|||||||
const [title, setTitle] = useState(pad.title);
|
const [title, setTitle] = useState(pad.title);
|
||||||
const { toast } = useToastProvider();
|
const { toast } = useToastProvider();
|
||||||
const [padPublic, setPadPublic] = useState(pad.is_public);
|
const [padPublic, setPadPublic] = useState(pad.is_public);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutate: updatePad,
|
mutate: updatePad,
|
||||||
@@ -92,10 +94,11 @@ export const ModalUpdatePad = ({ onClose, pad }: ModalUpdatePadProps) => {
|
|||||||
<Box
|
<Box
|
||||||
$margin={{ bottom: 'xl' }}
|
$margin={{ bottom: 'xl' }}
|
||||||
aria-label={t('Content modal to update the document')}
|
aria-label={t('Content modal to update the document')}
|
||||||
|
$gap="1rem"
|
||||||
>
|
>
|
||||||
<Text as="p" $margin={{ bottom: 'big' }}>
|
<Alert canClose={false} type={VariantType.INFO}>
|
||||||
{t('Enter the new name of the selected document.')}
|
<Text>{t('Enter the new name of the selected document.')}</Text>
|
||||||
</Text>
|
</Alert>
|
||||||
|
|
||||||
<Box $gap="1rem">
|
<Box $gap="1rem">
|
||||||
<InputPadName
|
<InputPadName
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Loader,
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -136,12 +137,15 @@ export const ModalPDF = ({ onClose, templateOptions, pad }: ModalPDFProps) => {
|
|||||||
<Box
|
<Box
|
||||||
$margin={{ bottom: 'xl' }}
|
$margin={{ bottom: 'xl' }}
|
||||||
aria-label={t('Content modal to generate a PDF')}
|
aria-label={t('Content modal to generate a PDF')}
|
||||||
|
$gap="1.5rem"
|
||||||
>
|
>
|
||||||
<Text as="p" $margin={{ bottom: 'big' }}>
|
<Alert canClose={false} type={VariantType.INFO}>
|
||||||
{t(
|
<Text>
|
||||||
'Generate a PDF from your document, it will be inserted in the selected template.',
|
{t(
|
||||||
)}
|
'Generate a PDF from your document, it will be inserted in the selected template.',
|
||||||
</Text>
|
)}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
clearable={false}
|
clearable={false}
|
||||||
|
|||||||
@@ -120,9 +120,7 @@ describe('PanelPads', () => {
|
|||||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(
|
await screen.findByText('Something bad happens, please retry.'),
|
||||||
'Something bad happens, please refresh the page.',
|
|
||||||
),
|
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { Loader } from '@openfun/cunningham-react';
|
|||||||
import React, { useMemo, useRef } from 'react';
|
import React, { useMemo, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Box, Text } from '@/components';
|
import { APIError } from '@/api';
|
||||||
|
import { Box, Text, TextErrors } from '@/components';
|
||||||
import { InfiniteScroll } from '@/components/InfiniteScroll';
|
import { InfiniteScroll } from '@/components/InfiniteScroll';
|
||||||
import { Pad, usePads } from '@/features/pads/pad-management';
|
import { Pad, usePads } from '@/features/pads/pad-management';
|
||||||
|
|
||||||
@@ -12,23 +13,13 @@ import { PadItem } from './PadItem';
|
|||||||
|
|
||||||
interface PanelTeamsStateProps {
|
interface PanelTeamsStateProps {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isError: boolean;
|
error: APIError<unknown> | null;
|
||||||
pads?: Pad[];
|
pads?: Pad[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const PadListState = ({ isLoading, isError, pads }: PanelTeamsStateProps) => {
|
const PadListState = ({ isLoading, error, pads }: PanelTeamsStateProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<Box $justify="center" $margin={{ bottom: 'big' }}>
|
|
||||||
<Text $theme="danger" $align="center" $textAlign="center">
|
|
||||||
{t('Something bad happens, please refresh the page.')}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Box $align="center" $margin="large">
|
<Box $align="center" $margin="large">
|
||||||
@@ -37,7 +28,7 @@ const PadListState = ({ isLoading, isError, pads }: PanelTeamsStateProps) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pads?.length) {
|
if (!pads?.length && !error) {
|
||||||
return (
|
return (
|
||||||
<Box $justify="center" $margin="small">
|
<Box $justify="center" $margin="small">
|
||||||
<Text
|
<Text
|
||||||
@@ -57,14 +48,35 @@ const PadListState = ({ isLoading, isError, pads }: PanelTeamsStateProps) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return pads.map((pad) => <PadItem pad={pad} key={pad.id} />);
|
return (
|
||||||
|
<>
|
||||||
|
{pads?.map((pad) => <PadItem pad={pad} key={pad.id} />)}
|
||||||
|
{error && (
|
||||||
|
<Box
|
||||||
|
$justify="center"
|
||||||
|
$margin={{ vertical: 'big', horizontal: 'auto' }}
|
||||||
|
>
|
||||||
|
<TextErrors
|
||||||
|
causes={error.cause}
|
||||||
|
icon={
|
||||||
|
error.status === 502 ? (
|
||||||
|
<Text className="material-icons" $theme="danger">
|
||||||
|
wifi_off
|
||||||
|
</Text>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PadList = () => {
|
export const PadList = () => {
|
||||||
const ordering = usePadPanelStore((state) => state.ordering);
|
const ordering = usePadPanelStore((state) => state.ordering);
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isError,
|
error,
|
||||||
isLoading,
|
isLoading,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
@@ -93,7 +105,7 @@ export const PadList = () => {
|
|||||||
$margin={{ top: 'none' }}
|
$margin={{ top: 'none' }}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
>
|
>
|
||||||
<PadListState isLoading={isLoading} isError={isError} pads={pads} />
|
<PadListState isLoading={isLoading} error={error} pads={pads} />
|
||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { useRouter as useNavigate } from 'next/navigation';
|
|||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { ReactElement } from 'react';
|
import { ReactElement } from 'react';
|
||||||
|
|
||||||
import { Box } from '@/components';
|
import { Box, Text, TextErrors } from '@/components/';
|
||||||
import { TextErrors } from '@/components/TextErrors';
|
|
||||||
import { PadEditor } from '@/features/pads/pad-editor';
|
import { PadEditor } from '@/features/pads/pad-editor';
|
||||||
import { usePad } from '@/features/pads/pad-management';
|
import { usePad } from '@/features/pads/pad-management';
|
||||||
import { PadLayout } from '@/layouts';
|
import { PadLayout } from '@/layouts';
|
||||||
@@ -36,7 +35,20 @@ const Pad = ({ id }: PadProps) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <TextErrors causes={error.cause} />;
|
return (
|
||||||
|
<Box $margin="large">
|
||||||
|
<TextErrors
|
||||||
|
causes={error.cause}
|
||||||
|
icon={
|
||||||
|
error.status === 502 ? (
|
||||||
|
<Text className="material-icons" $theme="danger">
|
||||||
|
wifi_off
|
||||||
|
</Text>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading || !pad) {
|
if (isLoading || !pad) {
|
||||||
|
|||||||
Reference in New Issue
Block a user