mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-25 00:07:47 +01:00
fix playground custom strategy parameters debugging (#1213)
* fix playground custom strategy parameters debugging * fix playground strategy parameters and chips consistency
This commit is contained in:
parent
2c3b0bbebd
commit
d2225c62c9
@ -7,7 +7,7 @@ import {
|
||||
StyledToggleButtonOn,
|
||||
} from '../StyledToggleButton';
|
||||
import { ConditionallyRender } from '../../../../ConditionallyRender/ConditionallyRender';
|
||||
import { IConstraint } from '../../../../../../interfaces/strategy';
|
||||
import { IConstraint } from 'interfaces/strategy';
|
||||
|
||||
interface CaseSensitiveButtonProps {
|
||||
localConstraint: IConstraint;
|
||||
|
@ -8,8 +8,7 @@ export const useStyles = makeStyles()(theme => ({
|
||||
},
|
||||
titleRow: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1.5),
|
||||
marginTop: theme.spacing(1.5),
|
||||
},
|
||||
|
@ -69,12 +69,22 @@ export const FeatureDetails = ({
|
||||
<Typography variant={'subtitle1'} className={styles.name}>
|
||||
{feature.name}
|
||||
</Typography>
|
||||
<span>
|
||||
<PlaygroundResultChip
|
||||
enabled={feature.isEnabled}
|
||||
label={feature.isEnabled ? 'True' : 'False'}
|
||||
/>
|
||||
</span>
|
||||
<ConditionallyRender
|
||||
condition={feature?.strategies?.result !== 'unknown'}
|
||||
show={() => (
|
||||
<PlaygroundResultChip
|
||||
enabled={feature.isEnabled}
|
||||
label={feature.isEnabled ? 'True' : 'False'}
|
||||
/>
|
||||
)}
|
||||
elseShow={() => (
|
||||
<PlaygroundResultChip
|
||||
enabled="unknown"
|
||||
label={'Unknown'}
|
||||
showIcon={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<IconButton onClick={onCloseClick} className={styles.icon}>
|
||||
<CloseOutlined />
|
||||
|
@ -44,11 +44,6 @@ export const FeatureStrategyItem = ({
|
||||
showIcon={false}
|
||||
enabled={result.enabled}
|
||||
label={label}
|
||||
size={
|
||||
result.evaluationStatus === 'incomplete'
|
||||
? 'large'
|
||||
: 'default'
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { styled, Tooltip, Typography, useTheme } from '@mui/material';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { PlaygroundSingleValue } from './PlaygroundSingleValue/PlaygroundSingleValue';
|
||||
import { PLaygroundMultipleValues } from './PlaygroundMultipleValues/PLaygroundMultipleValues';
|
||||
import { PLaygroundMultipleValues } from './PlaygroundMultipleValues/PlaygroundMultipleValues';
|
||||
import React from 'react';
|
||||
import { useStyles } from '../../ConstraintAccordion.styles';
|
||||
import { CancelOutlined } from '@mui/icons-material';
|
||||
|
@ -59,7 +59,7 @@ export const PLaygroundMultipleValues = ({
|
||||
noWrap={true}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
does not match any values{' '}
|
||||
does not match values{' '}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
@ -35,7 +35,7 @@ export const PlaygroundSingleValue = ({
|
||||
condition={!Boolean(constraint.result)}
|
||||
show={
|
||||
<Typography variant={'body1'} color={'error'}>
|
||||
does not match any values{' '}
|
||||
does not match values{' '}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
|
@ -0,0 +1,97 @@
|
||||
import { Box, styled, Typography, useTheme } from '@mui/material';
|
||||
import { CancelOutlined } from '@mui/icons-material';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
|
||||
|
||||
interface ICustomParameterItem {
|
||||
text: string;
|
||||
input?: string | null;
|
||||
isRequired?: boolean;
|
||||
}
|
||||
|
||||
const StyledWrapper = styled(Box)(({ theme }) => ({
|
||||
width: '100%',
|
||||
padding: theme.spacing(2, 3),
|
||||
borderRadius: theme.shape.borderRadiusMedium,
|
||||
border: `1px solid ${theme.palette.dividerAlternative}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
}));
|
||||
|
||||
export const CustomParameterItem = ({
|
||||
text,
|
||||
input = null,
|
||||
isRequired = false,
|
||||
}: ICustomParameterItem) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const color = input === null ? 'error' : 'neutral';
|
||||
const requiredError = isRequired && input === null;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
color={theme.palette[color].main}
|
||||
sx={{ minWidth: 118 }}
|
||||
>
|
||||
{`${input === null ? 'no value' : input}`}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(requiredError)}
|
||||
show={
|
||||
<>
|
||||
<Typography
|
||||
component="span"
|
||||
color={theme.palette.error.main}
|
||||
>
|
||||
{' required parameter '}
|
||||
</Typography>
|
||||
<StringTruncator
|
||||
maxWidth="300"
|
||||
text={text}
|
||||
maxLength={50}
|
||||
/>
|
||||
<Typography
|
||||
component="span"
|
||||
color={theme.palette.error.main}
|
||||
>
|
||||
{' is not set '}
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
elseShow={
|
||||
<>
|
||||
<Typography
|
||||
component="span"
|
||||
color="text.disabled"
|
||||
>
|
||||
{' set on parameter '}
|
||||
</Typography>
|
||||
<StringTruncator
|
||||
maxWidth="300"
|
||||
text={text}
|
||||
maxLength={50}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(requiredError)}
|
||||
show={<CancelOutlined color={'error'} />}
|
||||
elseShow={<div />}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
@ -4,23 +4,18 @@ import {
|
||||
parseParameterString,
|
||||
parseParameterStrings,
|
||||
} from 'utils/parseParameter';
|
||||
import { PlaygroundParameterItem } from '../PlaygroundParameterItem/PlaygroundParameterItem';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
|
||||
import { Chip } from '@mui/material';
|
||||
import PercentageCircle from 'component/common/PercentageCircle/PercentageCircle';
|
||||
import { PlaygroundConstraintSchema } from 'component/playground/Playground/interfaces/playground.model';
|
||||
import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies';
|
||||
import { CustomParameterItem } from './CustomParameterItem/CustomParameterItem';
|
||||
|
||||
interface ICustomStrategyProps {
|
||||
parameters: { [key: string]: string };
|
||||
strategyName: string;
|
||||
constraints: PlaygroundConstraintSchema[];
|
||||
}
|
||||
|
||||
export const CustomStrategyParams: VFC<ICustomStrategyProps> = ({
|
||||
strategyName,
|
||||
constraints,
|
||||
parameters,
|
||||
}) => {
|
||||
const { strategies } = useStrategies();
|
||||
@ -32,109 +27,84 @@ export const CustomStrategyParams: VFC<ICustomStrategyProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderCustomStrategyParameters = () => {
|
||||
return definition?.parameters.map((param: any, index: number) => {
|
||||
const notLastItem = index !== definition?.parameters?.length - 1;
|
||||
switch (param?.type) {
|
||||
case 'list':
|
||||
const values = parseParameterStrings(
|
||||
parameters[param.name]
|
||||
);
|
||||
return (
|
||||
<Fragment key={param?.name}>
|
||||
<PlaygroundParameterItem
|
||||
value={values}
|
||||
text={param.name}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={notLastItem}
|
||||
show={<StrategySeparator text="AND" />}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
case 'percentage':
|
||||
return (
|
||||
<Fragment key={param?.name}>
|
||||
<div>
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="success"
|
||||
label={`${parameters[param.name]}%`}
|
||||
/>{' '}
|
||||
of your base{' '}
|
||||
{constraints?.length > 0
|
||||
? 'who match constraints'
|
||||
: ''}{' '}
|
||||
is included.
|
||||
</div>
|
||||
<PercentageCircle
|
||||
percentage={parseParameterNumber(
|
||||
parameters[param.name]
|
||||
)}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={notLastItem}
|
||||
show={<StrategySeparator text="AND" />}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
case 'boolean':
|
||||
const bool = Boolean(parameters[param?.name]);
|
||||
return (
|
||||
<Fragment key={param?.name}>
|
||||
<PlaygroundParameterItem
|
||||
value={bool ? ['True'] : []}
|
||||
text={param.name}
|
||||
showReason={!bool}
|
||||
input={bool ? bool : 'no value'}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={notLastItem}
|
||||
show={<StrategySeparator text="AND" />}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
case 'string':
|
||||
const value =
|
||||
parseParameterString(parameters[param.name]) ??
|
||||
'no value';
|
||||
return (
|
||||
<Fragment key={param?.name}>
|
||||
<PlaygroundParameterItem
|
||||
value={value !== '' ? [value] : []}
|
||||
text={param.name}
|
||||
showReason={value === ''}
|
||||
input={value !== '' ? value : 'no value'}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={notLastItem}
|
||||
show={<StrategySeparator text="AND" />}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
case 'number':
|
||||
const number = parseParameterNumber(parameters[param.name]);
|
||||
return (
|
||||
<Fragment key={param?.name}>
|
||||
<PlaygroundParameterItem
|
||||
value={Boolean(number) ? [number] : []}
|
||||
text={param.name}
|
||||
showReason={Boolean(number)}
|
||||
input={Boolean(number) ? number : 'no value'}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={notLastItem}
|
||||
show={<StrategySeparator text="AND" />}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
case 'default':
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
const items = definition?.parameters.map(param => {
|
||||
const paramValue = parameters[param.name];
|
||||
const isRequired = param.required;
|
||||
|
||||
return <>{renderCustomStrategyParameters()}</>;
|
||||
switch (param?.type) {
|
||||
case 'list':
|
||||
const values = parseParameterStrings(paramValue);
|
||||
return (
|
||||
<CustomParameterItem
|
||||
isRequired={isRequired}
|
||||
text={param.name}
|
||||
input={values?.length > 0 ? values.join(', ') : null}
|
||||
/>
|
||||
);
|
||||
case 'percentage':
|
||||
const percentage = parseParameterNumber(paramValue);
|
||||
const correctPercentage = !(
|
||||
paramValue === undefined ||
|
||||
paramValue === '' ||
|
||||
percentage < 0 ||
|
||||
percentage > 100
|
||||
);
|
||||
return (
|
||||
<CustomParameterItem
|
||||
text={param.name}
|
||||
isRequired={isRequired}
|
||||
input={correctPercentage ? `${percentage}%` : undefined}
|
||||
/>
|
||||
);
|
||||
case 'boolean':
|
||||
const bool = ['true', 'false'].includes(paramValue)
|
||||
? paramValue
|
||||
: undefined;
|
||||
return (
|
||||
<CustomParameterItem
|
||||
isRequired={isRequired}
|
||||
text={param.name}
|
||||
input={paramValue !== undefined ? bool : undefined}
|
||||
/>
|
||||
);
|
||||
case 'string':
|
||||
const value = parseParameterString(paramValue);
|
||||
return (
|
||||
<CustomParameterItem
|
||||
text={param.name}
|
||||
isRequired={isRequired}
|
||||
input={value !== undefined ? value : undefined}
|
||||
/>
|
||||
);
|
||||
case 'number':
|
||||
const isCorrect = !(
|
||||
paramValue === undefined || paramValue === ''
|
||||
);
|
||||
const number = parseParameterNumber(paramValue);
|
||||
return (
|
||||
<CustomParameterItem
|
||||
text={param.name}
|
||||
isRequired={isRequired}
|
||||
input={isCorrect ? `${number}` : undefined}
|
||||
/>
|
||||
);
|
||||
case 'default':
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={index}>
|
||||
<ConditionallyRender
|
||||
condition={index > 0}
|
||||
show={<StrategySeparator text="AND" />}
|
||||
/>
|
||||
{item}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -4,8 +4,8 @@ export const useStyles = makeStyles()(theme => ({
|
||||
container: {
|
||||
width: '100%',
|
||||
padding: theme.spacing(2, 3),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: theme.shape.borderRadiusMedium,
|
||||
border: `1px solid ${theme.palette.dividerAlternative}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
@ -13,7 +13,7 @@ export const useStyles = makeStyles()(theme => ({
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
padding: theme.spacing(2, 3),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: theme.shape.borderRadiusMedium,
|
||||
border: `1px solid ${theme.palette.dividerAlternative}`,
|
||||
},
|
||||
}));
|
||||
|
@ -23,10 +23,6 @@ const StyledStrategyExecutionWrapper = styled('div')(({ theme }) => ({
|
||||
padding: theme.spacing(0),
|
||||
}));
|
||||
|
||||
const StyledParamWrapper = styled('div')(({ theme }) => ({
|
||||
padding: theme.spacing(0, 0),
|
||||
}));
|
||||
|
||||
export const StrategyExecution: VFC<IStrategyExecutionProps> = ({
|
||||
strategyResult,
|
||||
input,
|
||||
@ -93,20 +89,15 @@ export const StrategyExecution: VFC<IStrategyExecutionProps> = ({
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<StyledParamWrapper>
|
||||
<PlaygroundResultStrategyExecutionParameters
|
||||
parameters={parameters}
|
||||
constraints={constraints}
|
||||
input={input}
|
||||
/>
|
||||
<StyledParamWrapper sx={{ pt: 2 }}>
|
||||
<CustomStrategyParams
|
||||
strategyName={strategyResult.name}
|
||||
parameters={parameters}
|
||||
constraints={constraints}
|
||||
/>
|
||||
</StyledParamWrapper>
|
||||
</StyledParamWrapper>
|
||||
<PlaygroundResultStrategyExecutionParameters
|
||||
parameters={parameters}
|
||||
constraints={constraints}
|
||||
input={input}
|
||||
/>
|
||||
<CustomStrategyParams
|
||||
strategyName={strategyResult.name}
|
||||
parameters={parameters}
|
||||
/>
|
||||
</StyledStrategyExecutionWrapper>
|
||||
);
|
||||
};
|
||||
|
@ -18,16 +18,16 @@ const StyledChipWrapper = styled(Box)(() => ({
|
||||
}));
|
||||
|
||||
export const FeatureStatusCell = ({ feature }: IFeatureStatusCellProps) => {
|
||||
const enabled = feature.isEnabled
|
||||
? true
|
||||
: feature.strategies?.result === false
|
||||
? false
|
||||
: 'unknown';
|
||||
const label = feature.isEnabled
|
||||
? 'True'
|
||||
: feature.strategies?.result === false
|
||||
? 'False'
|
||||
: 'Unknown';
|
||||
const [enabled, label]: [boolean | 'unknown', string] = (() => {
|
||||
if (feature?.isEnabled) {
|
||||
return [true, 'True'];
|
||||
}
|
||||
if (feature?.strategies?.result === false) {
|
||||
return [false, 'False'];
|
||||
}
|
||||
return ['unknown', 'Unknown'];
|
||||
})();
|
||||
|
||||
return (
|
||||
<StyledCellBox>
|
||||
<StyledChipWrapper data-loading>
|
||||
@ -35,7 +35,6 @@ export const FeatureStatusCell = ({ feature }: IFeatureStatusCellProps) => {
|
||||
enabled={enabled}
|
||||
label={label}
|
||||
showIcon={enabled !== 'unknown'}
|
||||
size={'medium'}
|
||||
/>
|
||||
</StyledChipWrapper>
|
||||
</StyledCellBox>
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { VFC } from 'react';
|
||||
import { Chip, styled, useTheme } from '@mui/material';
|
||||
import { colors } from '../../../../../themes/colors';
|
||||
import { ConditionallyRender } from '../../../../common/ConditionallyRender/ConditionallyRender';
|
||||
import React from 'react';
|
||||
import { ReactComponent as FeatureEnabledIcon } from '../../../../../assets/icons/isenabled-true.svg';
|
||||
import { ReactComponent as FeatureDisabledIcon } from '../../../../../assets/icons/isenabled-false.svg';
|
||||
import { colors } from 'themes/colors';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { ReactComponent as FeatureEnabledIcon } from 'assets/icons/isenabled-true.svg';
|
||||
import { ReactComponent as FeatureDisabledIcon } from 'assets/icons/isenabled-false.svg';
|
||||
import { WarningOutlined } from '@mui/icons-material';
|
||||
|
||||
interface IResultChipProps {
|
||||
@ -11,21 +11,18 @@ interface IResultChipProps {
|
||||
label: string;
|
||||
// Result icon - defaults to true
|
||||
showIcon?: boolean;
|
||||
size?: 'default' | 'medium' | 'large';
|
||||
}
|
||||
|
||||
export const StyledChip = styled(Chip)<{ width?: number }>(
|
||||
({ theme, width }) => ({
|
||||
width: width ?? 60,
|
||||
height: 24,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
['& .MuiChip-label']: {
|
||||
padding: 0,
|
||||
paddingLeft: theme.spacing(0.5),
|
||||
},
|
||||
})
|
||||
);
|
||||
export const StyledChip = styled(Chip)(({ theme, icon }) => ({
|
||||
padding: theme.spacing(0, 1),
|
||||
height: 24,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
['& .MuiChip-label']: {
|
||||
padding: 0,
|
||||
paddingLeft: Boolean(icon) ? theme.spacing(0.5) : 0,
|
||||
},
|
||||
}));
|
||||
|
||||
export const StyledFalseChip = styled(StyledChip)(({ theme }) => ({
|
||||
border: `1px solid ${theme.palette.error.main}`,
|
||||
@ -60,12 +57,11 @@ export const StyledUnknownChip = styled(StyledChip)(({ theme }) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export const PlaygroundResultChip = ({
|
||||
export const PlaygroundResultChip: VFC<IResultChipProps> = ({
|
||||
enabled,
|
||||
label,
|
||||
showIcon = true,
|
||||
size = 'default',
|
||||
}: IResultChipProps) => {
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const icon = (
|
||||
<ConditionallyRender
|
||||
@ -91,10 +87,6 @@ export const PlaygroundResultChip = ({
|
||||
/>
|
||||
);
|
||||
|
||||
let chipWidth = 60;
|
||||
if (size === 'medium') chipWidth = 72;
|
||||
if (size === 'large') chipWidth = 100;
|
||||
|
||||
return (
|
||||
<ConditionallyRender
|
||||
condition={enabled === 'unknown' || enabled === 'unevaluated'}
|
||||
@ -102,7 +94,6 @@ export const PlaygroundResultChip = ({
|
||||
<StyledUnknownChip
|
||||
icon={showIcon ? icon : undefined}
|
||||
label={label}
|
||||
width={chipWidth}
|
||||
/>
|
||||
}
|
||||
elseShow={
|
||||
@ -112,14 +103,12 @@ export const PlaygroundResultChip = ({
|
||||
<StyledTrueChip
|
||||
icon={showIcon ? icon : undefined}
|
||||
label={label}
|
||||
width={chipWidth}
|
||||
/>
|
||||
}
|
||||
elseShow={
|
||||
<StyledFalseChip
|
||||
icon={showIcon ? icon : undefined}
|
||||
label={label}
|
||||
width={chipWidth}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
@ -47,10 +47,10 @@ export default createTheme({
|
||||
bold: 700,
|
||||
},
|
||||
shape: {
|
||||
borderRadius: '4px',
|
||||
borderRadiusMedium: '8px',
|
||||
borderRadiusLarge: '12px',
|
||||
borderRadiusExtraLarge: '20px',
|
||||
borderRadius: 4,
|
||||
borderRadiusMedium: 8,
|
||||
borderRadiusLarge: 12,
|
||||
borderRadiusExtraLarge: 20,
|
||||
tableRowHeight: 64,
|
||||
tableRowHeightCompact: 56,
|
||||
tableRowHeightDense: 48,
|
||||
|
@ -120,9 +120,9 @@ declare module '@mui/material/styles' {
|
||||
|
||||
declare module '@mui/system/createTheme/shape' {
|
||||
interface Shape {
|
||||
borderRadiusMedium: string;
|
||||
borderRadiusLarge: string;
|
||||
borderRadiusExtraLarge: string;
|
||||
borderRadiusMedium: number;
|
||||
borderRadiusLarge: number;
|
||||
borderRadiusExtraLarge: number;
|
||||
tableRowHeight: number;
|
||||
tableRowHeightCompact: number;
|
||||
tableRowHeightDense: number;
|
||||
|
Loading…
Reference in New Issue
Block a user