mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-31 13:47:02 +02:00
Feat(UI): new strategy variant chips (#9507)
- new way of showing strategy variants - fixed wrapping issue in strategy editing, for a lot of variants defined (`SplitPreviewSlider.tsx` change) - aligned difference between API and manually added types
This commit is contained in:
parent
5ad3178590
commit
863788d7b3
@ -274,7 +274,7 @@ describe('Strategy change conflict detection', () => {
|
|||||||
name: 'variant1',
|
name: 'variant1',
|
||||||
weight: 1000,
|
weight: 1000,
|
||||||
payload: {
|
payload: {
|
||||||
type: 'string',
|
type: 'string' as const,
|
||||||
value: 'beaty',
|
value: 'beaty',
|
||||||
},
|
},
|
||||||
stickiness: 'userId',
|
stickiness: 'userId',
|
||||||
|
@ -12,11 +12,19 @@ const StyledContainer = styled('div')(({ theme }) => ({
|
|||||||
gap: theme.spacing(1),
|
gap: theme.spacing(1),
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
fontSize: theme.typography.body2.fontSize,
|
fontSize: theme.typography.body2.fontSize,
|
||||||
padding: theme.spacing(2, 3),
|
margin: theme.spacing(2, 3),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledContent = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledType = styled('span')(({ theme }) => ({
|
const StyledType = styled('span')(({ theme }) => ({
|
||||||
display: 'block',
|
display: 'block',
|
||||||
|
flexShrink: 0,
|
||||||
fontSize: theme.fontSizes.smallerBody,
|
fontSize: theme.fontSizes.smallerBody,
|
||||||
fontWeight: theme.typography.fontWeightBold,
|
fontWeight: theme.typography.fontWeightBold,
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
@ -46,13 +54,15 @@ export const StrategyEvaluationItem: FC<StrategyItemProps> = ({
|
|||||||
}) => (
|
}) => (
|
||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
<StyledType>{type}</StyledType>
|
<StyledType>{type}</StyledType>
|
||||||
{children}
|
<StyledContent>
|
||||||
{values && values?.length > 0 ? (
|
{children}
|
||||||
<StyledValuesGroup>
|
{values && values?.length > 0 ? (
|
||||||
{values?.map((value, index) => (
|
<StyledValuesGroup>
|
||||||
<StyledValue key={`${value}#${index}`} label={value} />
|
{values?.map((value, index) => (
|
||||||
))}
|
<StyledValue key={`${value}#${index}`} label={value} />
|
||||||
</StyledValuesGroup>
|
))}
|
||||||
) : null}
|
</StyledValuesGroup>
|
||||||
|
) : null}
|
||||||
|
</StyledContent>
|
||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
);
|
);
|
||||||
|
@ -15,7 +15,7 @@ interface ISidebarModalProps {
|
|||||||
|
|
||||||
const TRANSITION_DURATION = 250;
|
const TRANSITION_DURATION = 250;
|
||||||
|
|
||||||
const ModalContentWrapper = styled('div')({
|
const ModalContentWrapper = styled('div')(({ theme }) => ({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
@ -24,7 +24,8 @@ const ModalContentWrapper = styled('div')({
|
|||||||
maxWidth: '98vw',
|
maxWidth: '98vw',
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
boxShadow: '0 0 1rem rgba(0, 0, 0, 0.25)',
|
boxShadow: '0 0 1rem rgba(0, 0, 0, 0.25)',
|
||||||
});
|
background: theme.palette.background.paper,
|
||||||
|
}));
|
||||||
|
|
||||||
const FixedWidthContentWrapper = styled(ModalContentWrapper)({
|
const FixedWidthContentWrapper = styled(ModalContentWrapper)({
|
||||||
width: 1300,
|
width: 1300,
|
||||||
|
@ -0,0 +1,46 @@
|
|||||||
|
import { StrategyEvaluationChip } from 'component/common/ConstraintsList/StrategyEvaluationChip/StrategyEvaluationChip';
|
||||||
|
import { StrategyEvaluationItem } from 'component/common/ConstraintsList/StrategyEvaluationItem/StrategyEvaluationItem';
|
||||||
|
import type { ParametersSchema, StrategyVariantSchema } from 'openapi';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
import { parseParameterNumber } from 'utils/parseParameter';
|
||||||
|
import { RolloutVariants } from './RolloutVariants/RolloutVariants';
|
||||||
|
|
||||||
|
export const RolloutParameter: FC<{
|
||||||
|
value: string;
|
||||||
|
parameters?: ParametersSchema;
|
||||||
|
hasConstraints?: boolean;
|
||||||
|
variants?: StrategyVariantSchema[];
|
||||||
|
displayGroupId?: boolean;
|
||||||
|
}> = ({ value, parameters, hasConstraints, variants, displayGroupId }) => {
|
||||||
|
const percentage = parseParameterNumber(value);
|
||||||
|
|
||||||
|
const explainStickiness =
|
||||||
|
typeof parameters?.stickiness === 'string' &&
|
||||||
|
parameters?.stickiness !== 'default';
|
||||||
|
const stickiness = explainStickiness ? (
|
||||||
|
<>
|
||||||
|
with <strong>{parameters.stickiness}</strong>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StrategyEvaluationItem type='Rollout %'>
|
||||||
|
<StrategyEvaluationChip label={`${percentage}%`} /> of your base{' '}
|
||||||
|
{stickiness}
|
||||||
|
<span>
|
||||||
|
{hasConstraints ? 'who match constraints ' : ' '}
|
||||||
|
is included.
|
||||||
|
</span>
|
||||||
|
{displayGroupId && parameters?.groupId ? (
|
||||||
|
<StrategyEvaluationChip
|
||||||
|
label={`groupId: ${parameters?.groupId}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</StrategyEvaluationItem>
|
||||||
|
<RolloutVariants variants={variants} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,71 @@
|
|||||||
|
import { styled } from '@mui/material';
|
||||||
|
import { StrategyEvaluationChip } from 'component/common/ConstraintsList/StrategyEvaluationChip/StrategyEvaluationChip';
|
||||||
|
import { StrategyEvaluationItem } from 'component/common/ConstraintsList/StrategyEvaluationItem/StrategyEvaluationItem';
|
||||||
|
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
|
||||||
|
import type { StrategyVariantSchema } from 'openapi';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
const StyledVariantChip = styled(StrategyEvaluationChip)<{ order: number }>(
|
||||||
|
({ theme, order }) => {
|
||||||
|
const variantColor =
|
||||||
|
theme.palette.variants[order % theme.palette.variants.length];
|
||||||
|
|
||||||
|
return {
|
||||||
|
borderRadius: theme.shape.borderRadiusExtraLarge,
|
||||||
|
border: 'none',
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
background:
|
||||||
|
// TODO: adjust theme.palette.variants
|
||||||
|
theme.mode === 'dark'
|
||||||
|
? `hsl(from ${variantColor} h calc(s - 30) calc(l - 45))`
|
||||||
|
: `hsl(from ${variantColor} h s calc(l + 5))`,
|
||||||
|
fontWeight: theme.typography.fontWeightRegular,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const StyledPayloadHeader = styled('div')(({ theme }) => ({
|
||||||
|
fontSize: theme.typography.body2.fontSize,
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const RolloutVariants: FC<{
|
||||||
|
variants?: StrategyVariantSchema[];
|
||||||
|
}> = ({ variants }) => {
|
||||||
|
if (!variants?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StrategyEvaluationItem type={`Variants (${variants.length})`}>
|
||||||
|
{variants.map((variant, i) => (
|
||||||
|
<HtmlTooltip
|
||||||
|
arrow
|
||||||
|
title={
|
||||||
|
variant.payload?.value ? (
|
||||||
|
<div>
|
||||||
|
<StyledPayloadHeader>
|
||||||
|
Payload:
|
||||||
|
</StyledPayloadHeader>
|
||||||
|
<code>{variant.payload?.value}</code>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
key={variant.name}
|
||||||
|
>
|
||||||
|
<StyledVariantChip
|
||||||
|
key={variant.name}
|
||||||
|
order={i}
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
{variant.weight / 10}% – {variant.name}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</HtmlTooltip>
|
||||||
|
))}
|
||||||
|
</StrategyEvaluationItem>
|
||||||
|
);
|
||||||
|
};
|
@ -1,6 +1,6 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import type { CreateFeatureStrategySchema } from 'openapi';
|
import type { FeatureStrategySchema } from 'openapi';
|
||||||
import type { IFeatureStrategyPayload } from 'interfaces/strategy';
|
import type { IFeatureStrategyPayload } from 'interfaces/strategy';
|
||||||
import { useUiFlag } from 'hooks/useUiFlag';
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
import { StrategyExecution as LegacyStrategyExecution } from './LegacyStrategyExecution';
|
import { StrategyExecution as LegacyStrategyExecution } from './LegacyStrategyExecution';
|
||||||
@ -20,7 +20,7 @@ const FilterContainer = styled('div', {
|
|||||||
);
|
);
|
||||||
|
|
||||||
type StrategyExecutionProps = {
|
type StrategyExecutionProps = {
|
||||||
strategy: IFeatureStrategyPayload | CreateFeatureStrategySchema;
|
strategy: IFeatureStrategyPayload | FeatureStrategySchema;
|
||||||
displayGroupId?: boolean;
|
displayGroupId?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -32,7 +32,10 @@ export const StrategyExecution: FC<StrategyExecutionProps> = ({
|
|||||||
const { segments } = useSegments();
|
const { segments } = useSegments();
|
||||||
const { isCustomStrategy, customStrategyParameters: customStrategyItems } =
|
const { isCustomStrategy, customStrategyParameters: customStrategyItems } =
|
||||||
useCustomStrategyParameters(strategy, strategies);
|
useCustomStrategyParameters(strategy, strategies);
|
||||||
const strategyParameters = useStrategyParameters(strategy, displayGroupId);
|
const strategyParameters = useStrategyParameters(
|
||||||
|
strategy as FeatureStrategySchema,
|
||||||
|
displayGroupId,
|
||||||
|
);
|
||||||
const { constraints } = strategy;
|
const { constraints } = strategy;
|
||||||
const strategySegments = segments?.filter((segment) =>
|
const strategySegments = segments?.filter((segment) =>
|
||||||
strategy.segments?.includes(segment.id),
|
strategy.segments?.includes(segment.id),
|
||||||
@ -52,7 +55,7 @@ export const StrategyExecution: FC<StrategyExecutionProps> = ({
|
|||||||
<FilterContainer grayscale={strategy.disabled === true}>
|
<FilterContainer grayscale={strategy.disabled === true}>
|
||||||
<ConstraintsList>
|
<ConstraintsList>
|
||||||
{strategySegments?.map((segment) => (
|
{strategySegments?.map((segment) => (
|
||||||
<SegmentItem segment={segment} />
|
<SegmentItem segment={segment} key={segment.id} />
|
||||||
))}
|
))}
|
||||||
{constraints?.map((constraint, index) => (
|
{constraints?.map((constraint, index) => (
|
||||||
<ConstraintItem
|
<ConstraintItem
|
||||||
|
@ -1,71 +1,30 @@
|
|||||||
import { type FC, useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { StrategyEvaluationChip } from 'component/common/ConstraintsList/StrategyEvaluationChip/StrategyEvaluationChip';
|
import { parseParameterStrings } from 'utils/parseParameter';
|
||||||
import {
|
|
||||||
parseParameterNumber,
|
|
||||||
parseParameterStrings,
|
|
||||||
} from 'utils/parseParameter';
|
|
||||||
import { StrategyEvaluationItem } from 'component/common/ConstraintsList/StrategyEvaluationItem/StrategyEvaluationItem';
|
import { StrategyEvaluationItem } from 'component/common/ConstraintsList/StrategyEvaluationItem/StrategyEvaluationItem';
|
||||||
import type { IFeatureStrategyPayload } from 'interfaces/strategy';
|
import type { FeatureStrategySchema } from 'openapi';
|
||||||
import type { CreateFeatureStrategySchema } from 'openapi';
|
import { RolloutParameter } from '../RolloutParameter/RolloutParameter';
|
||||||
|
|
||||||
const RolloutParameter: FC<{
|
|
||||||
value?: string | number;
|
|
||||||
parameters?: (
|
|
||||||
| IFeatureStrategyPayload
|
|
||||||
| CreateFeatureStrategySchema
|
|
||||||
)['parameters'];
|
|
||||||
hasConstraints?: boolean;
|
|
||||||
displayGroupId?: boolean;
|
|
||||||
}> = ({ value, parameters, hasConstraints, displayGroupId }) => {
|
|
||||||
const percentage = parseParameterNumber(value);
|
|
||||||
|
|
||||||
const explainStickiness =
|
|
||||||
typeof parameters?.stickiness === 'string' &&
|
|
||||||
parameters?.stickiness !== 'default';
|
|
||||||
const stickiness = explainStickiness ? (
|
|
||||||
<>
|
|
||||||
with <strong>{parameters.stickiness}</strong>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StrategyEvaluationItem type='Rollout %'>
|
|
||||||
<StrategyEvaluationChip label={`${percentage}%`} /> of your base{' '}
|
|
||||||
{stickiness}
|
|
||||||
<span>
|
|
||||||
{hasConstraints ? 'who match constraints ' : ' '}
|
|
||||||
is included.
|
|
||||||
</span>
|
|
||||||
{displayGroupId && parameters?.groupId ? (
|
|
||||||
<StrategyEvaluationChip
|
|
||||||
label={`groupId: ${parameters?.groupId}`}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</StrategyEvaluationItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useStrategyParameters = (
|
export const useStrategyParameters = (
|
||||||
strategy: IFeatureStrategyPayload | CreateFeatureStrategySchema,
|
strategy: Partial<FeatureStrategySchema>,
|
||||||
displayGroupId?: boolean,
|
displayGroupId?: boolean,
|
||||||
) => {
|
) => {
|
||||||
const { constraints } = strategy;
|
const { constraints, variants } = strategy;
|
||||||
const { parameters } = strategy;
|
const { parameters } = strategy;
|
||||||
const hasConstraints = Boolean(constraints?.length);
|
const hasConstraints = Boolean(constraints?.length);
|
||||||
const parameterKeys = parameters ? Object.keys(parameters) : [];
|
const parameterKeys = parameters ? Object.keys(parameters) : [];
|
||||||
const mapPredefinedStrategies = (key: string) => {
|
const mapPredefinedStrategies = (key: string) => {
|
||||||
const type = key.toLocaleLowerCase();
|
const type = key.toLocaleLowerCase();
|
||||||
|
const value = parameters?.[key] || '';
|
||||||
|
|
||||||
if (type === 'rollout') {
|
if (type === 'rollout') {
|
||||||
return (
|
return (
|
||||||
<RolloutParameter
|
<RolloutParameter
|
||||||
key={key}
|
key={key}
|
||||||
value={parameters?.[key]}
|
value={value}
|
||||||
parameters={parameters}
|
parameters={parameters}
|
||||||
hasConstraints={hasConstraints}
|
hasConstraints={hasConstraints}
|
||||||
displayGroupId={displayGroupId}
|
displayGroupId={displayGroupId}
|
||||||
|
variants={variants}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -75,7 +34,7 @@ export const useStrategyParameters = (
|
|||||||
<StrategyEvaluationItem
|
<StrategyEvaluationItem
|
||||||
key={key}
|
key={key}
|
||||||
type={key}
|
type={key}
|
||||||
values={parseParameterStrings(parameters?.[key])}
|
values={parseParameterStrings(value)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -88,7 +47,7 @@ export const useStrategyParameters = (
|
|||||||
[
|
[
|
||||||
...parameterKeys.map(mapPredefinedStrategies),
|
...parameterKeys.map(mapPredefinedStrategies),
|
||||||
strategy.name === 'default' ? (
|
strategy.name === 'default' ? (
|
||||||
<RolloutParameter value={100} />
|
<RolloutParameter value='100' />
|
||||||
) : null,
|
) : null,
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
[parameters, hasConstraints, displayGroupId],
|
[parameters, hasConstraints, displayGroupId],
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
import type { DragEventHandler, FC, ReactNode } from 'react';
|
import type { DragEventHandler, FC, ReactNode } from 'react';
|
||||||
import type { IFeatureStrategy } from 'interfaces/strategy';
|
import type { IFeatureStrategy } from 'interfaces/strategy';
|
||||||
import { StrategyExecution } from './StrategyExecution/StrategyExecution';
|
import { StrategyExecution } from './StrategyExecution/StrategyExecution';
|
||||||
import SplitPreviewSlider from 'component/feature/StrategyTypes/SplitPreviewSlider/SplitPreviewSlider';
|
|
||||||
import { Box } from '@mui/material';
|
|
||||||
import { StrategyItemContainer } from 'component/common/StrategyItemContainer/StrategyItemContainer';
|
import { StrategyItemContainer } from 'component/common/StrategyItemContainer/StrategyItemContainer';
|
||||||
|
|
||||||
type StrategyItemProps = {
|
type StrategyItemProps = {
|
||||||
@ -29,16 +27,6 @@ export const StrategyItem: FC<StrategyItemProps> = ({
|
|||||||
headerItemsRight={headerItemsRight}
|
headerItemsRight={headerItemsRight}
|
||||||
>
|
>
|
||||||
<StrategyExecution strategy={strategy} />
|
<StrategyExecution strategy={strategy} />
|
||||||
|
|
||||||
{strategy.variants &&
|
|
||||||
strategy.variants.length > 0 &&
|
|
||||||
(strategy.disabled ? (
|
|
||||||
<Box sx={{ opacity: '0.5' }}>
|
|
||||||
<SplitPreviewSlider variants={strategy.variants} />
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<SplitPreviewSlider variants={strategy.variants} />
|
|
||||||
))}
|
|
||||||
</StrategyItemContainer>
|
</StrategyItemContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -149,7 +149,7 @@ const payloadOptions = [
|
|||||||
{ key: 'number', label: 'number' },
|
{ key: 'number', label: 'number' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const EMPTY_PAYLOAD = { type: 'string', value: '' };
|
const EMPTY_PAYLOAD = { type: 'string' as const, value: '' };
|
||||||
|
|
||||||
enum ErrorField {
|
enum ErrorField {
|
||||||
NAME = 'name',
|
NAME = 'name',
|
||||||
@ -438,7 +438,7 @@ export const VariantForm = ({
|
|||||||
clearError(ErrorField.PAYLOAD);
|
clearError(ErrorField.PAYLOAD);
|
||||||
setPayload((payload) => ({
|
setPayload((payload) => ({
|
||||||
...payload,
|
...payload,
|
||||||
type: e.target.value,
|
type: e.target.value as typeof payload.type,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -48,6 +48,7 @@ const StyledVariantBoxContainer = styled(Box)(() => ({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginLeft: 'auto',
|
marginLeft: 'auto',
|
||||||
|
flexWrap: 'wrap',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledVariantBox = styled(Box, {
|
const StyledVariantBox = styled(Box, {
|
||||||
|
@ -19,7 +19,7 @@ test('should render variants', async () => {
|
|||||||
weight: 1000,
|
weight: 1000,
|
||||||
weightType: 'variable' as const,
|
weightType: 'variable' as const,
|
||||||
payload: {
|
payload: {
|
||||||
type: 'string',
|
type: 'string' as const,
|
||||||
value: 'variantValue',
|
value: 'variantValue',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -99,6 +99,9 @@ export interface IFeatureEnvironmentWithCrEnabled extends IFeatureEnvironment {
|
|||||||
crEnabled?: boolean;
|
crEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use `StrategyVariantSchema` from openapi
|
||||||
|
*/
|
||||||
export interface IFeatureVariant {
|
export interface IFeatureVariant {
|
||||||
name: string;
|
name: string;
|
||||||
stickiness: string;
|
stickiness: string;
|
||||||
@ -114,7 +117,7 @@ export interface IOverride {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IPayload {
|
export interface IPayload {
|
||||||
type: string;
|
type: 'string' | 'number' | 'json' | 'csv';
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -22,6 +22,9 @@ export interface IFeatureStrategyParameters {
|
|||||||
[key: string]: string | number | undefined;
|
[key: string]: string | number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use `FeatureStrategySchema` from openapi
|
||||||
|
*/
|
||||||
export interface IFeatureStrategyPayload {
|
export interface IFeatureStrategyPayload {
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
Loading…
Reference in New Issue
Block a user