mirror of
https://github.com/lingble/twenty.git
synced 2025-10-30 04:12:28 +00:00
fix: when field metadata SELECT type is edited update view groups (#8344)
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@@ -21,6 +21,7 @@ const baseFields = `
|
||||
settings
|
||||
`;
|
||||
|
||||
|
||||
export const queries = {
|
||||
deleteMetadataField: gql`
|
||||
mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) {
|
||||
@@ -29,6 +30,37 @@ export const queries = {
|
||||
}
|
||||
}
|
||||
`,
|
||||
findManyViewsQuery: gql`
|
||||
query FindManyViews($filter: ViewFilterInput, $orderBy: [ViewOrderByInput], $lastCursor: String, $limit: Int) {
|
||||
views(filter: $filter, orderBy: $orderBy, first: $limit, after: $lastCursor) {
|
||||
edges {
|
||||
node {
|
||||
__typename
|
||||
id
|
||||
viewGroups {
|
||||
edges {
|
||||
node {
|
||||
__typename
|
||||
fieldMetadataId
|
||||
fieldValue
|
||||
id
|
||||
isVisible
|
||||
position
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
}`,
|
||||
deleteMetadataFieldRelation: gql`
|
||||
mutation DeleteOneRelationMetadataItem($idToDelete: UUID!) {
|
||||
deleteOneRelation(input: { id: $idToDelete }) {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { MockedProvider } from '@apollo/client/testing';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { act, ReactNode } from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { act } from 'react';
|
||||
|
||||
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
|
||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { FieldMetadataType, RelationDefinitionType } from '~/generated/graphql';
|
||||
|
||||
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
|
||||
import {
|
||||
FIELD_METADATA_ID,
|
||||
FIELD_RELATION_METADATA_ID,
|
||||
@@ -58,6 +57,31 @@ const fieldRelationMetadataItem: FieldMetadataItem = {
|
||||
};
|
||||
|
||||
const mocks = [
|
||||
{
|
||||
request: {
|
||||
query: queries.findManyViewsQuery,
|
||||
variables: {
|
||||
filter: {
|
||||
objectMetadataId: { eq: '25611fce-6637-4089-b0ca-91afeec95784' },
|
||||
},
|
||||
},
|
||||
},
|
||||
result: jest.fn(() => ({
|
||||
data: {
|
||||
views: {
|
||||
__typename: 'ViewConnection',
|
||||
totalCount: 0,
|
||||
pageInfo: {
|
||||
__typename: 'PageInfo',
|
||||
hasNextPage: false,
|
||||
startCursor: '',
|
||||
endCursor: '',
|
||||
},
|
||||
edges: [],
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
request: {
|
||||
query: queries.deleteMetadataField,
|
||||
@@ -115,13 +139,9 @@ const mocks = [
|
||||
},
|
||||
];
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<RecoilRoot>
|
||||
<MockedProvider mocks={mocks} addTypename={false}>
|
||||
{children}
|
||||
</MockedProvider>
|
||||
</RecoilRoot>
|
||||
);
|
||||
const Wrapper = getJestMetadataAndApolloMocksWrapper({
|
||||
apolloMocks: mocks,
|
||||
});
|
||||
|
||||
describe('useFieldMetadataItem', () => {
|
||||
it('should activateMetadataField', async () => {
|
||||
@@ -130,7 +150,10 @@ describe('useFieldMetadataItem', () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const res = await result.current.activateMetadataField(fieldMetadataItem);
|
||||
const res = await result.current.activateMetadataField(
|
||||
fieldMetadataItem.id,
|
||||
objectMetadataId,
|
||||
);
|
||||
|
||||
expect(res.data).toEqual({
|
||||
updateOneField: responseData.default,
|
||||
@@ -162,8 +185,10 @@ describe('useFieldMetadataItem', () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const res =
|
||||
await result.current.deactivateMetadataField(fieldMetadataItem);
|
||||
const res = await result.current.deactivateMetadataField(
|
||||
fieldMetadataItem.id,
|
||||
objectMetadataId,
|
||||
);
|
||||
|
||||
expect(res.data).toEqual({
|
||||
updateOneField: responseData.default,
|
||||
|
||||
@@ -40,15 +40,23 @@ export const useFieldMetadataItem = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const activateMetadataField = (metadataField: FieldMetadataItem) =>
|
||||
const activateMetadataField = (
|
||||
fieldMetadataId: string,
|
||||
objectMetadataId: string,
|
||||
) =>
|
||||
updateOneFieldMetadataItem({
|
||||
fieldMetadataIdToUpdate: metadataField.id,
|
||||
objectMetadataId: objectMetadataId,
|
||||
fieldMetadataIdToUpdate: fieldMetadataId,
|
||||
updatePayload: { isActive: true },
|
||||
});
|
||||
|
||||
const deactivateMetadataField = (metadataField: FieldMetadataItem) =>
|
||||
const deactivateMetadataField = (
|
||||
fieldMetadataId: string,
|
||||
objectMetadataId: string,
|
||||
) =>
|
||||
updateOneFieldMetadataItem({
|
||||
fieldMetadataIdToUpdate: metadataField.id,
|
||||
objectMetadataId: objectMetadataId,
|
||||
fieldMetadataIdToUpdate: fieldMetadataId,
|
||||
updatePayload: { isActive: false },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMutation } from '@apollo/client';
|
||||
import { useApolloClient, useMutation } from '@apollo/client';
|
||||
import { getOperationName } from '@apollo/client/utilities';
|
||||
|
||||
import {
|
||||
@@ -9,10 +9,27 @@ import {
|
||||
import { UPDATE_ONE_FIELD_METADATA_ITEM } from '../graphql/mutations';
|
||||
import { FIND_MANY_OBJECT_METADATA_ITEMS } from '../graphql/queries';
|
||||
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useFindManyRecordsQuery } from '@/object-record/hooks/useFindManyRecordsQuery';
|
||||
import { useApolloMetadataClient } from './useApolloMetadataClient';
|
||||
|
||||
export const useUpdateOneFieldMetadataItem = () => {
|
||||
const apolloMetadataClient = useApolloMetadataClient();
|
||||
const apolloClient = useApolloClient();
|
||||
|
||||
const { findManyRecordsQuery } = useFindManyRecordsQuery({
|
||||
objectNameSingular: CoreObjectNameSingular.View,
|
||||
recordGqlFields: {
|
||||
id: true,
|
||||
viewGroups: {
|
||||
id: true,
|
||||
fieldMetadataId: true,
|
||||
isVisible: true,
|
||||
fieldValue: true,
|
||||
position: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [mutate] = useMutation<
|
||||
UpdateOneFieldMetadataItemMutation,
|
||||
@@ -22,9 +39,11 @@ export const useUpdateOneFieldMetadataItem = () => {
|
||||
});
|
||||
|
||||
const updateOneFieldMetadataItem = async ({
|
||||
objectMetadataId,
|
||||
fieldMetadataIdToUpdate,
|
||||
updatePayload,
|
||||
}: {
|
||||
objectMetadataId: string;
|
||||
fieldMetadataIdToUpdate: UpdateOneFieldMetadataItemMutationVariables['idToUpdate'];
|
||||
updatePayload: Pick<
|
||||
UpdateOneFieldMetadataItemMutationVariables['updatePayload'],
|
||||
@@ -37,7 +56,7 @@ export const useUpdateOneFieldMetadataItem = () => {
|
||||
| 'options'
|
||||
>;
|
||||
}) => {
|
||||
return await mutate({
|
||||
const result = await mutate({
|
||||
variables: {
|
||||
idToUpdate: fieldMetadataIdToUpdate,
|
||||
updatePayload: {
|
||||
@@ -48,6 +67,20 @@ export const useUpdateOneFieldMetadataItem = () => {
|
||||
awaitRefetchQueries: true,
|
||||
refetchQueries: [getOperationName(FIND_MANY_OBJECT_METADATA_ITEMS) ?? ''],
|
||||
});
|
||||
|
||||
await apolloClient.query({
|
||||
query: findManyRecordsQuery,
|
||||
variables: {
|
||||
filter: {
|
||||
objectMetadataId: {
|
||||
eq: objectMetadataId,
|
||||
},
|
||||
},
|
||||
},
|
||||
fetchPolicy: 'network-only',
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
|
||||
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
|
||||
import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition';
|
||||
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
|
||||
|
||||
export const useSetRecordBoardColumns = (recordBoardId?: string) => {
|
||||
const { scopeId, columnIdsState, columnsFamilySelector } =
|
||||
@@ -19,12 +19,10 @@ export const useSetRecordBoardColumns = (recordBoardId?: string) => {
|
||||
.filter(({ isVisible }) => isVisible)
|
||||
.map(({ id }) => id);
|
||||
|
||||
if (isDeeplyEqual(currentColumnsIds, columnIds)) {
|
||||
return;
|
||||
if (!isDeeplyEqual(currentColumnsIds, columnIds)) {
|
||||
set(columnIdsState, columnIds);
|
||||
}
|
||||
|
||||
set(columnIdsState, columnIds);
|
||||
|
||||
columns.forEach((column) => {
|
||||
const currentColumn = snapshot
|
||||
.getLoadable(columnsFamilySelector(column.id))
|
||||
|
||||
@@ -127,7 +127,10 @@ export const SettingsObjectFieldItemTableRow = ({
|
||||
const handleDisableField = async (
|
||||
activeFieldMetadatItem: FieldMetadataItem,
|
||||
) => {
|
||||
await deactivateMetadataField(activeFieldMetadatItem);
|
||||
await deactivateMetadataField(
|
||||
activeFieldMetadatItem.id,
|
||||
objectMetadataItem.id,
|
||||
);
|
||||
|
||||
const deletedViewIds = allViews
|
||||
.map((view) => {
|
||||
@@ -272,7 +275,9 @@ export const SettingsObjectFieldItemTableRow = ({
|
||||
isCustomField={fieldMetadataItem.isCustom === true}
|
||||
scopeKey={fieldMetadataItem.id}
|
||||
onEdit={() => navigate(linkToNavigate)}
|
||||
onActivate={() => activateMetadataField(fieldMetadataItem)}
|
||||
onActivate={() =>
|
||||
activateMetadataField(fieldMetadataItem.id, objectMetadataItem.id)
|
||||
}
|
||||
onDelete={() => deleteMetadataField(fieldMetadataItem)}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -137,6 +137,7 @@ export const SettingsObjectFieldEdit = () => {
|
||||
|
||||
if (isDefined(relationFieldMetadataItem)) {
|
||||
await updateOneFieldMetadataItem({
|
||||
objectMetadataId: objectMetadataItem.id,
|
||||
fieldMetadataIdToUpdate: relationFieldMetadataItem.id,
|
||||
updatePayload: formValues.relation.field,
|
||||
});
|
||||
@@ -152,6 +153,7 @@ export const SettingsObjectFieldEdit = () => {
|
||||
);
|
||||
|
||||
await updateOneFieldMetadataItem({
|
||||
objectMetadataId: objectMetadataItem.id,
|
||||
fieldMetadataIdToUpdate: fieldMetadataItem.id,
|
||||
updatePayload: formattedInput,
|
||||
});
|
||||
@@ -168,12 +170,12 @@ export const SettingsObjectFieldEdit = () => {
|
||||
};
|
||||
|
||||
const handleDeactivate = async () => {
|
||||
await deactivateMetadataField(fieldMetadataItem);
|
||||
await deactivateMetadataField(fieldMetadataItem.id, objectMetadataItem.id);
|
||||
navigate(`/settings/objects/${objectSlug}`);
|
||||
};
|
||||
|
||||
const handleActivate = async () => {
|
||||
await activateMetadataField(fieldMetadataItem);
|
||||
await activateMetadataField(fieldMetadataItem.id, objectMetadataItem.id);
|
||||
navigate(`/settings/objects/${objectSlug}`);
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dto
|
||||
import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/field-metadata-validation.service';
|
||||
import { FieldMetadataResolver } from 'src/engine/metadata-modules/field-metadata/field-metadata.resolver';
|
||||
import { FieldMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/field-metadata/interceptors/field-metadata-graphql-api-exception.interceptor';
|
||||
import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service';
|
||||
import { IsFieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-default-value.validator';
|
||||
import { IsFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-options.validator';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
@@ -48,6 +49,7 @@ import { UpdateFieldInput } from './dtos/update-field.input';
|
||||
services: [
|
||||
IsFieldMetadataDefaultValue,
|
||||
FieldMetadataService,
|
||||
FieldMetadataRelatedRecordsService,
|
||||
FieldMetadataValidationService,
|
||||
],
|
||||
resolvers: [
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FieldMetadataException,
|
||||
FieldMetadataExceptionCode,
|
||||
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
|
||||
import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service';
|
||||
import { assertDoesNotNullifyDefaultValueForNonNullableField } from 'src/engine/metadata-modules/field-metadata/utils/assert-does-not-nullify-default-value-for-non-nullable-field.util';
|
||||
import {
|
||||
computeColumnName,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
} from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
|
||||
import { generateNullable } from 'src/engine/metadata-modules/field-metadata/utils/generate-nullable';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { isSelectFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-select-field-metadata-type.util';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
|
||||
import {
|
||||
@@ -83,6 +85,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
||||
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
private readonly fieldMetadataValidationService: FieldMetadataValidationService,
|
||||
private readonly fieldMetadataRelatedRecordsService: FieldMetadataRelatedRecordsService,
|
||||
) {
|
||||
super(fieldMetadataRepository);
|
||||
}
|
||||
@@ -418,6 +421,16 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
updatedFieldMetadata.isActive &&
|
||||
isSelectFieldMetadataType(updatedFieldMetadata.type)
|
||||
) {
|
||||
await this.fieldMetadataRelatedRecordsService.updateRelatedViewGroups(
|
||||
existingFieldMetadata,
|
||||
updatedFieldMetadata,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
fieldMetadataInput.name ||
|
||||
updatableFieldInput.options ||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { In } from 'typeorm';
|
||||
|
||||
import {
|
||||
FieldMetadataComplexOption,
|
||||
FieldMetadataDefaultOption,
|
||||
} from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { isSelectFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-select-field-metadata-type.util';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity';
|
||||
import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
|
||||
|
||||
type Differences<T> = {
|
||||
created: T[];
|
||||
updated: { old: T; new: T }[];
|
||||
deleted: T[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FieldMetadataRelatedRecordsService {
|
||||
constructor(
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
) {}
|
||||
|
||||
public async updateRelatedViewGroups(
|
||||
oldFieldMetadata: FieldMetadataEntity,
|
||||
newFieldMetadata: FieldMetadataEntity,
|
||||
) {
|
||||
if (
|
||||
!isSelectFieldMetadataType(newFieldMetadata.type) ||
|
||||
!isSelectFieldMetadataType(oldFieldMetadata.type)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const views = await this.getFieldMetadataViews(newFieldMetadata);
|
||||
|
||||
const { created, updated, deleted } = this.getOptionsDifferences(
|
||||
oldFieldMetadata.options,
|
||||
newFieldMetadata.options,
|
||||
);
|
||||
|
||||
const viewGroupRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewGroupWorkspaceEntity>(
|
||||
newFieldMetadata.workspaceId,
|
||||
'viewGroup',
|
||||
);
|
||||
|
||||
for (const view of views) {
|
||||
const maxPosition = view.viewGroups.reduce(
|
||||
(max, viewGroup) => Math.max(max, viewGroup.position),
|
||||
0,
|
||||
);
|
||||
|
||||
const viewGroupsToCreate = created.map((option, index) =>
|
||||
viewGroupRepository.create({
|
||||
fieldMetadataId: newFieldMetadata.id,
|
||||
fieldValue: option.value,
|
||||
position: maxPosition + index,
|
||||
isVisible: true,
|
||||
viewId: view.id,
|
||||
}),
|
||||
);
|
||||
|
||||
await viewGroupRepository.insert(viewGroupsToCreate);
|
||||
|
||||
for (const { old: oldOption, new: newOption } of updated) {
|
||||
const viewGroup = view.viewGroups.find(
|
||||
(viewGroup) => viewGroup.fieldValue === oldOption.value,
|
||||
);
|
||||
|
||||
if (!viewGroup) {
|
||||
throw new Error(`View group not found for option ${oldOption.value}`);
|
||||
}
|
||||
|
||||
await viewGroupRepository.update(
|
||||
{
|
||||
id: viewGroup.id,
|
||||
},
|
||||
{
|
||||
fieldValue: newOption.value,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const valuesToDelete = deleted.map((option) => option.value);
|
||||
|
||||
await viewGroupRepository.delete({
|
||||
fieldMetadataId: newFieldMetadata.id,
|
||||
fieldValue: In(valuesToDelete),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private getOptionsDifferences(
|
||||
oldOptions: (FieldMetadataDefaultOption | FieldMetadataComplexOption)[],
|
||||
newOptions: (FieldMetadataDefaultOption | FieldMetadataComplexOption)[],
|
||||
) {
|
||||
const differences: Differences<
|
||||
FieldMetadataDefaultOption | FieldMetadataComplexOption
|
||||
> = {
|
||||
created: [],
|
||||
updated: [],
|
||||
deleted: [],
|
||||
};
|
||||
|
||||
const oldOptionsMap = new Map(
|
||||
oldOptions.map((option) => [option.id, option]),
|
||||
);
|
||||
const newOptionsMap = new Map(
|
||||
newOptions.map((option) => [option.id, option]),
|
||||
);
|
||||
|
||||
for (const newOption of newOptions) {
|
||||
const oldOption = oldOptionsMap.get(newOption.id);
|
||||
|
||||
if (!oldOption) {
|
||||
differences.created.push(newOption);
|
||||
} else if (oldOption.value !== newOption.value) {
|
||||
differences.updated.push({ old: oldOption, new: newOption });
|
||||
}
|
||||
}
|
||||
|
||||
for (const oldOption of oldOptions) {
|
||||
if (!newOptionsMap.has(oldOption.id)) {
|
||||
differences.deleted.push(oldOption);
|
||||
}
|
||||
}
|
||||
|
||||
return differences;
|
||||
}
|
||||
|
||||
private async getFieldMetadataViews(
|
||||
fieldMetadata: FieldMetadataEntity,
|
||||
): Promise<ViewWorkspaceEntity[]> {
|
||||
const viewRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewWorkspaceEntity>(
|
||||
fieldMetadata.workspaceId,
|
||||
'view',
|
||||
);
|
||||
|
||||
return await viewRepository.find({
|
||||
where: {
|
||||
kanbanFieldMetadataId: fieldMetadata.id,
|
||||
},
|
||||
relations: ['viewGroups'],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
|
||||
export const isSelectFieldMetadataType = (
|
||||
type: FieldMetadataType,
|
||||
): type is FieldMetadataType.SELECT => {
|
||||
return type === FieldMetadataType.SELECT;
|
||||
};
|
||||
Reference in New Issue
Block a user