mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-21 13:47:39 +02:00
feat: dependent features in playground (#4930)
This commit is contained in:
parent
5d11d5b0fd
commit
2c7587ba4b
@ -60,6 +60,17 @@ export const FeatureDetails = ({
|
|||||||
theme.palette.success.main,
|
theme.palette.success.main,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (
|
||||||
|
feature.hasUnsatisfiedDependency &&
|
||||||
|
!feature.isEnabledInCurrentEnvironment
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
`This feature toggle is False in ${input?.environment} because `,
|
||||||
|
'parent dependency is not satisfied and the environment is disabled',
|
||||||
|
theme.palette.error.main,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if (!feature.isEnabledInCurrentEnvironment)
|
if (!feature.isEnabledInCurrentEnvironment)
|
||||||
return [
|
return [
|
||||||
`This feature toggle is False in ${input?.environment} because `,
|
`This feature toggle is False in ${input?.environment} because `,
|
||||||
@ -81,6 +92,14 @@ export const FeatureDetails = ({
|
|||||||
theme.palette.warning.main,
|
theme.palette.warning.main,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (feature.hasUnsatisfiedDependency) {
|
||||||
|
return [
|
||||||
|
`This feature toggle is False in ${input?.environment} because `,
|
||||||
|
'parent dependency is not satisfied',
|
||||||
|
theme.palette.error.main,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
`This feature toggle is False in ${input?.environment} because `,
|
`This feature toggle is False in ${input?.environment} because `,
|
||||||
'all strategies are either False or could not be fully evaluated',
|
'all strategies are either False or could not be fully evaluated',
|
||||||
|
@ -27,7 +27,10 @@ export const hasCustomStrategies = (feature: PlaygroundFeatureSchema) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const hasOnlyCustomStrategies = (feature: PlaygroundFeatureSchema) => {
|
export const hasOnlyCustomStrategies = (feature: PlaygroundFeatureSchema) => {
|
||||||
return !feature.strategies?.data?.find((strategy) =>
|
return (
|
||||||
DEFAULT_STRATEGIES.includes(strategy.name),
|
feature.strategies?.data?.length > 0 &&
|
||||||
|
!feature.strategies?.data?.find((strategy) =>
|
||||||
|
DEFAULT_STRATEGIES.includes(strategy.name),
|
||||||
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -28,12 +28,13 @@ export const PlaygroundResultFeatureStrategyList = ({
|
|||||||
/>
|
/>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={
|
condition={
|
||||||
!feature.isEnabledInCurrentEnvironment &&
|
(feature.hasUnsatisfiedDependency ||
|
||||||
|
!feature.isEnabledInCurrentEnvironment) &&
|
||||||
Boolean(feature?.strategies?.data)
|
Boolean(feature?.strategies?.data)
|
||||||
}
|
}
|
||||||
show={
|
show={
|
||||||
<WrappedPlaygroundResultStrategyList
|
<WrappedPlaygroundResultStrategyList
|
||||||
strategies={feature?.strategies}
|
feature={feature}
|
||||||
input={input}
|
input={input}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import { Alert, Box, styled, Typography } from '@mui/material';
|
|||||||
import {
|
import {
|
||||||
PlaygroundStrategySchema,
|
PlaygroundStrategySchema,
|
||||||
PlaygroundRequestSchema,
|
PlaygroundRequestSchema,
|
||||||
PlaygroundFeatureSchemaStrategies,
|
PlaygroundFeatureSchema,
|
||||||
} from 'openapi';
|
} from 'openapi';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { FeatureStrategyItem } from './StrategyItem/FeatureStrategyItem';
|
import { FeatureStrategyItem } from './StrategyItem/FeatureStrategyItem';
|
||||||
@ -67,24 +67,40 @@ export const PlaygroundResultStrategyLists = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
interface IWrappedPlaygroundResultStrategyListProps {
|
interface IWrappedPlaygroundResultStrategyListProps {
|
||||||
strategies: PlaygroundFeatureSchemaStrategies;
|
feature: PlaygroundFeatureSchema;
|
||||||
input?: PlaygroundRequestSchema;
|
input?: PlaygroundRequestSchema;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveHintText = (feature: PlaygroundFeatureSchema) => {
|
||||||
|
if (
|
||||||
|
feature.hasUnsatisfiedDependency &&
|
||||||
|
!feature.isEnabledInCurrentEnvironment
|
||||||
|
) {
|
||||||
|
return 'If environment was enabled and parent dependencies were satisfied';
|
||||||
|
}
|
||||||
|
if (feature.hasUnsatisfiedDependency) {
|
||||||
|
return 'If parent dependencies were satisfied';
|
||||||
|
}
|
||||||
|
if (!feature.isEnabledInCurrentEnvironment) {
|
||||||
|
return 'If environment was enabled';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
export const WrappedPlaygroundResultStrategyList = ({
|
export const WrappedPlaygroundResultStrategyList = ({
|
||||||
strategies,
|
feature,
|
||||||
input,
|
input,
|
||||||
}: IWrappedPlaygroundResultStrategyListProps) => {
|
}: IWrappedPlaygroundResultStrategyListProps) => {
|
||||||
return (
|
return (
|
||||||
<StyledAlertWrapper sx={{ pb: 1, mt: 2 }}>
|
<StyledAlertWrapper sx={{ pb: 1, mt: 2 }}>
|
||||||
<StyledAlert severity={'info'} color={'warning'}>
|
<StyledAlert severity={'info'} color={'warning'}>
|
||||||
If environment was enabled, then this feature toggle would be{' '}
|
{resolveHintText(feature)}, then this feature toggle would be{' '}
|
||||||
{strategies?.result ? 'TRUE' : 'FALSE'} with strategies
|
{feature.strategies?.result ? 'TRUE' : 'FALSE'} with strategies
|
||||||
evaluated like so:{' '}
|
evaluated like so:{' '}
|
||||||
</StyledAlert>
|
</StyledAlert>
|
||||||
<StyledListWrapper sx={{ p: 2.5 }}>
|
<StyledListWrapper sx={{ p: 2.5 }}>
|
||||||
<PlaygroundResultStrategyLists
|
<PlaygroundResultStrategyLists
|
||||||
strategies={strategies?.data || []}
|
strategies={feature.strategies?.data || []}
|
||||||
input={input}
|
input={input}
|
||||||
/>
|
/>
|
||||||
</StyledListWrapper>
|
</StyledListWrapper>
|
||||||
|
@ -19,6 +19,7 @@ export interface PlaygroundFeatureSchema {
|
|||||||
strategies: PlaygroundFeatureSchemaStrategies;
|
strategies: PlaygroundFeatureSchemaStrategies;
|
||||||
/** Whether the feature is active and would be evaluated in the provided environment in a normal SDK context. */
|
/** Whether the feature is active and would be evaluated in the provided environment in a normal SDK context. */
|
||||||
isEnabledInCurrentEnvironment: boolean;
|
isEnabledInCurrentEnvironment: boolean;
|
||||||
|
hasUnsatisfiedDependency?: boolean;
|
||||||
/** Whether this feature is enabled or not in the current environment.
|
/** Whether this feature is enabled or not in the current environment.
|
||||||
If a feature can't be fully evaluated (that is, `strategies.result` is `unknown`),
|
If a feature can't be fully evaluated (that is, `strategies.result` is `unknown`),
|
||||||
this will be `false` to align with how client SDKs treat unresolved feature states. */
|
this will be `false` to align with how client SDKs treat unresolved feature states. */
|
||||||
|
@ -3,5 +3,5 @@ import { FeatureDependency, FeatureDependencyId } from './dependent-features';
|
|||||||
export interface IDependentFeaturesStore {
|
export interface IDependentFeaturesStore {
|
||||||
upsert(featureDependency: FeatureDependency): Promise<void>;
|
upsert(featureDependency: FeatureDependency): Promise<void>;
|
||||||
delete(dependency: FeatureDependencyId): Promise<void>;
|
delete(dependency: FeatureDependencyId): Promise<void>;
|
||||||
deleteAll(child: string): Promise<void>;
|
deleteAll(child?: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
@ -41,7 +41,13 @@ export class DependentFeaturesStore implements IDependentFeaturesStore {
|
|||||||
.del();
|
.del();
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAll(feature: string): Promise<void> {
|
async deleteAll(feature: string | undefined): Promise<void> {
|
||||||
await this.db('dependent_features').andWhere('child', feature).del();
|
if (feature) {
|
||||||
|
await this.db('dependent_features')
|
||||||
|
.andWhere('child', feature)
|
||||||
|
.del();
|
||||||
|
} else {
|
||||||
|
await this.db('dependent_features').del();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,9 @@ let app: IUnleashTest;
|
|||||||
let db: ITestDb;
|
let db: ITestDb;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
db = await dbInit('advanced_playground', getLogger);
|
db = await dbInit('advanced_playground', getLogger, {
|
||||||
|
experimental: { flags: { dependentFeatures: true } },
|
||||||
|
});
|
||||||
app = await setupAppWithCustomConfig(
|
app = await setupAppWithCustomConfig(
|
||||||
db.stores,
|
db.stores,
|
||||||
{
|
{
|
||||||
@ -20,6 +22,7 @@ beforeAll(async () => {
|
|||||||
strictSchemaValidation: true,
|
strictSchemaValidation: true,
|
||||||
strategyVariant: true,
|
strategyVariant: true,
|
||||||
privateProjects: true,
|
privateProjects: true,
|
||||||
|
dependentFeatures: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -67,6 +70,7 @@ afterAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
await db.stores.dependentFeaturesStore.deleteAll();
|
||||||
await db.stores.featureToggleStore.deleteAll();
|
await db.stores.featureToggleStore.deleteAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -95,6 +99,36 @@ test('advanced playground evaluation with no toggles', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('advanced playground evaluation with parent dependency', async () => {
|
||||||
|
await createFeatureToggle('test-parent');
|
||||||
|
await createFeatureToggle('test-child');
|
||||||
|
await enableToggle('test-child');
|
||||||
|
await app.addDependency('test-child', 'test-parent');
|
||||||
|
|
||||||
|
const { body: result } = await app.request
|
||||||
|
.post('/api/admin/playground/advanced')
|
||||||
|
.send({
|
||||||
|
environments: ['default'],
|
||||||
|
projects: ['default'],
|
||||||
|
context: { appName: 'test' },
|
||||||
|
})
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const child = result.features[0].environments.default[0];
|
||||||
|
const parent = result.features[1].environments.default[0];
|
||||||
|
// child is disabled because of the parent
|
||||||
|
expect(child.hasUnsatisfiedDependency).toBe(true);
|
||||||
|
expect(child.isEnabled).toBe(false);
|
||||||
|
expect(child.isEnabledInCurrentEnvironment).toBe(true);
|
||||||
|
expect(child.variant).toEqual({
|
||||||
|
name: 'disabled',
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
expect(parent.hasUnsatisfiedDependency).toBe(false);
|
||||||
|
expect(parent.isEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('advanced playground evaluation happy path', async () => {
|
test('advanced playground evaluation happy path', async () => {
|
||||||
await createFeatureToggleWithStrategy('test-playground-feature');
|
await createFeatureToggleWithStrategy('test-playground-feature');
|
||||||
await enableToggle('test-playground-feature');
|
await enableToggle('test-playground-feature');
|
||||||
@ -128,6 +162,7 @@ test('advanced playground evaluation happy path', async () => {
|
|||||||
{
|
{
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
isEnabledInCurrentEnvironment: true,
|
isEnabledInCurrentEnvironment: true,
|
||||||
|
hasUnsatisfiedDependency: false,
|
||||||
strategies: {
|
strategies: {
|
||||||
result: true,
|
result: true,
|
||||||
data: [
|
data: [
|
||||||
@ -161,6 +196,7 @@ test('advanced playground evaluation happy path', async () => {
|
|||||||
{
|
{
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
isEnabledInCurrentEnvironment: true,
|
isEnabledInCurrentEnvironment: true,
|
||||||
|
hasUnsatisfiedDependency: false,
|
||||||
strategies: {
|
strategies: {
|
||||||
result: true,
|
result: true,
|
||||||
data: [
|
data: [
|
||||||
@ -194,6 +230,7 @@ test('advanced playground evaluation happy path', async () => {
|
|||||||
{
|
{
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
isEnabledInCurrentEnvironment: true,
|
isEnabledInCurrentEnvironment: true,
|
||||||
|
hasUnsatisfiedDependency: false,
|
||||||
strategies: {
|
strategies: {
|
||||||
result: true,
|
result: true,
|
||||||
data: [
|
data: [
|
||||||
@ -227,6 +264,7 @@ test('advanced playground evaluation happy path', async () => {
|
|||||||
{
|
{
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
isEnabledInCurrentEnvironment: true,
|
isEnabledInCurrentEnvironment: true,
|
||||||
|
hasUnsatisfiedDependency: false,
|
||||||
strategies: {
|
strategies: {
|
||||||
result: true,
|
result: true,
|
||||||
data: [
|
data: [
|
||||||
|
@ -27,6 +27,7 @@ export type FeatureStrategiesEvaluationResult = {
|
|||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
variants?: VariantDefinition[];
|
variants?: VariantDefinition[];
|
||||||
strategies: EvaluatedPlaygroundStrategy[];
|
strategies: EvaluatedPlaygroundStrategy[];
|
||||||
|
hasUnsatisfiedDependency?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class UnleashClient {
|
export default class UnleashClient {
|
||||||
@ -57,13 +58,66 @@ export default class UnleashClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isParentDependencySatisfied(
|
||||||
|
feature: FeatureInterface | undefined,
|
||||||
|
context: Context,
|
||||||
|
) {
|
||||||
|
if (!feature?.dependencies?.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return feature.dependencies.every((parent) => {
|
||||||
|
const parentToggle = this.repository.getToggle(parent.feature);
|
||||||
|
|
||||||
|
if (!parentToggle) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (parentToggle.dependencies?.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent.enabled !== false) {
|
||||||
|
if (!parentToggle.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (parent.variants?.length) {
|
||||||
|
return parent.variants.includes(
|
||||||
|
this.getVariant(parent.feature, context).name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
this.isEnabled(parent.feature, context, () => false)
|
||||||
|
.result === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
!parentToggle.enabled &&
|
||||||
|
!(
|
||||||
|
this.isEnabled(parent.feature, context, () => false)
|
||||||
|
.result === true
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
isEnabled(
|
isEnabled(
|
||||||
name: string,
|
name: string,
|
||||||
context: Context,
|
context: Context,
|
||||||
fallback: Function,
|
fallback: Function,
|
||||||
): FeatureStrategiesEvaluationResult {
|
): FeatureStrategiesEvaluationResult {
|
||||||
const feature = this.repository.getToggle(name);
|
const feature = this.repository.getToggle(name);
|
||||||
return this.isFeatureEnabled(feature, context, fallback);
|
|
||||||
|
const parentDependencySatisfied = this.isParentDependencySatisfied(
|
||||||
|
feature,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
const result = this.isFeatureEnabled(feature, context, fallback);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
hasUnsatisfiedDependency: !parentDependencySatisfied,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
isFeatureEnabled(
|
isFeatureEnabled(
|
||||||
@ -234,7 +288,10 @@ export default class UnleashClient {
|
|||||||
const fallback = fallbackVariant || getDefaultVariant();
|
const fallback = fallbackVariant || getDefaultVariant();
|
||||||
const feature = this.repository.getToggle(name);
|
const feature = this.repository.getToggle(name);
|
||||||
|
|
||||||
if (typeof feature === 'undefined') {
|
if (
|
||||||
|
typeof feature === 'undefined' ||
|
||||||
|
!this.isParentDependencySatisfied(feature, context)
|
||||||
|
) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,6 +3,12 @@ import { Segment } from './strategy/strategy';
|
|||||||
// eslint-disable-next-line import/no-cycle
|
// eslint-disable-next-line import/no-cycle
|
||||||
import { VariantDefinition } from './variant';
|
import { VariantDefinition } from './variant';
|
||||||
|
|
||||||
|
export interface Dependency {
|
||||||
|
feature: string;
|
||||||
|
variants?: string[];
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FeatureInterface {
|
export interface FeatureInterface {
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
@ -12,6 +18,7 @@ export interface FeatureInterface {
|
|||||||
impressionData: boolean;
|
impressionData: boolean;
|
||||||
strategies: StrategyTransportInterface[];
|
strategies: StrategyTransportInterface[];
|
||||||
variants: VariantDefinition[];
|
variants: VariantDefinition[];
|
||||||
|
dependencies?: Dependency[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientFeaturesResponse {
|
export interface ClientFeaturesResponse {
|
||||||
|
@ -42,6 +42,7 @@ export const mapFeaturesForClient = (
|
|||||||
operator: constraint.operator as unknown as Operator,
|
operator: constraint.operator as unknown as Operator,
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
|
dependencies: feature.dependencies,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const mapSegmentsForClient = (segments: ISegment[]): Segment[] =>
|
export const mapSegmentsForClient = (segments: ISegment[]): Segment[] =>
|
||||||
|
@ -21,6 +21,7 @@ import { AdvancedPlaygroundEnvironmentFeatureSchema } from '../../openapi/spec/a
|
|||||||
import { validateQueryComplexity } from './validateQueryComplexity';
|
import { validateQueryComplexity } from './validateQueryComplexity';
|
||||||
import { playgroundStrategyEvaluation } from 'lib/openapi';
|
import { playgroundStrategyEvaluation } from 'lib/openapi';
|
||||||
import { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType';
|
import { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType';
|
||||||
|
import { getDefaultVariant } from './feature-evaluator/variant';
|
||||||
|
|
||||||
type EvaluationInput = {
|
type EvaluationInput = {
|
||||||
features: FeatureConfigurationClient[];
|
features: FeatureConfigurationClient[];
|
||||||
@ -198,23 +199,29 @@ export class PlaygroundService {
|
|||||||
const strategyEvaluationResult: FeatureStrategiesEvaluationResult =
|
const strategyEvaluationResult: FeatureStrategiesEvaluationResult =
|
||||||
client.isEnabled(feature.name, clientContext);
|
client.isEnabled(feature.name, clientContext);
|
||||||
|
|
||||||
|
const hasUnsatisfiedDependency =
|
||||||
|
strategyEvaluationResult.hasUnsatisfiedDependency;
|
||||||
const isEnabled =
|
const isEnabled =
|
||||||
strategyEvaluationResult.result === true &&
|
strategyEvaluationResult.result === true &&
|
||||||
feature.enabled;
|
feature.enabled &&
|
||||||
|
!hasUnsatisfiedDependency;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isEnabled,
|
isEnabled,
|
||||||
isEnabledInCurrentEnvironment: feature.enabled,
|
isEnabledInCurrentEnvironment: feature.enabled,
|
||||||
|
hasUnsatisfiedDependency,
|
||||||
strategies: {
|
strategies: {
|
||||||
result: strategyEvaluationResult.result,
|
result: strategyEvaluationResult.result,
|
||||||
data: strategyEvaluationResult.strategies,
|
data: strategyEvaluationResult.strategies,
|
||||||
},
|
},
|
||||||
projectId: featureProject[feature.name],
|
projectId: featureProject[feature.name],
|
||||||
variant: client.forceGetVariant(
|
variant: isEnabled
|
||||||
feature.name,
|
? client.forceGetVariant(
|
||||||
strategyEvaluationResult,
|
feature.name,
|
||||||
clientContext,
|
strategyEvaluationResult,
|
||||||
),
|
clientContext,
|
||||||
|
)
|
||||||
|
: getDefaultVariant(),
|
||||||
name: feature.name,
|
name: feature.name,
|
||||||
environment,
|
environment,
|
||||||
context,
|
context,
|
||||||
|
@ -67,6 +67,11 @@ export const playgroundFeatureSchema = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
hasUnsatisfiedDependency: {
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'Whether the feature has a parent dependency that is not satisfied',
|
||||||
|
},
|
||||||
isEnabledInCurrentEnvironment: {
|
isEnabledInCurrentEnvironment: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description:
|
description:
|
||||||
|
Loading…
Reference in New Issue
Block a user