mirror of
https://github.com/twentyhq/twenty.git
synced 2025-01-03 09:42:01 +03:00
Add possibility to destroy a record (#9144)
There are two follow ups to this PR: - Bug: sometimes when opening Cmd+K from a deleted record, we are facing a global error - On Index page, actions in top right are displaying label and not short name - Implement multiple actions once refactoring on delete is complete --------- Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
This commit is contained in:
parent
360c34fd18
commit
1d627039c0
@ -11,16 +11,16 @@ import { computeContextStoreFilters } from '@/context-store/utils/computeContext
|
|||||||
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
|
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
|
||||||
import { useFavorites } from '@/favorites/hooks/useFavorites';
|
import { useFavorites } from '@/favorites/hooks/useFavorites';
|
||||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||||
|
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
|
||||||
import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount';
|
import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount';
|
||||||
import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords';
|
import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords';
|
||||||
|
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
|
||||||
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
|
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
|
||||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||||
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
|
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
|
||||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||||
import { useCallback, useContext, useState } from 'react';
|
import { useCallback, useContext, useState } from 'react';
|
||||||
import { IconTrash, isDefined } from 'twenty-ui';
|
import { IconTrash, isDefined } from 'twenty-ui';
|
||||||
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
|
|
||||||
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
|
|
||||||
|
|
||||||
export const useDeleteMultipleRecordsAction = ({
|
export const useDeleteMultipleRecordsAction = ({
|
||||||
objectMetadataItem,
|
objectMetadataItem,
|
||||||
@ -118,7 +118,8 @@ export const useDeleteMultipleRecordsAction = ({
|
|||||||
type: ActionMenuEntryType.Standard,
|
type: ActionMenuEntryType.Standard,
|
||||||
scope: ActionMenuEntryScope.RecordSelection,
|
scope: ActionMenuEntryScope.RecordSelection,
|
||||||
key: 'delete-multiple-records',
|
key: 'delete-multiple-records',
|
||||||
label: 'Delete',
|
label: 'Delete records',
|
||||||
|
shortLabel: 'Delete',
|
||||||
position,
|
position,
|
||||||
Icon: IconTrash,
|
Icon: IconTrash,
|
||||||
accent: 'danger',
|
accent: 'danger',
|
||||||
|
@ -36,6 +36,7 @@ export const useExportMultipleRecordsAction = ({
|
|||||||
key: 'export-multiple-records',
|
key: 'export-multiple-records',
|
||||||
position,
|
position,
|
||||||
label: displayedExportProgress(progress),
|
label: displayedExportProgress(progress),
|
||||||
|
shortLabel: 'Export',
|
||||||
Icon: IconDatabaseExport,
|
Icon: IconDatabaseExport,
|
||||||
accent: 'default',
|
accent: 'default',
|
||||||
onClick: () => download(),
|
onClick: () => download(),
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { useAddToFavoritesSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useAddToFavoritesSingleRecordAction';
|
import { useAddToFavoritesSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useAddToFavoritesSingleRecordAction';
|
||||||
import { useDeleteSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction';
|
import { useDeleteSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction';
|
||||||
|
import { useDestroySingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useDestroySingleRecordAction';
|
||||||
import { useNavigateToNextRecordSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useNavigateToNextRecordSingleRecordAction';
|
import { useNavigateToNextRecordSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useNavigateToNextRecordSingleRecordAction';
|
||||||
import { useNavigateToPreviousRecordSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useNavigateToPreviousRecordSingleRecordAction';
|
import { useNavigateToPreviousRecordSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useNavigateToPreviousRecordSingleRecordAction';
|
||||||
import { useRemoveFromFavoritesSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useRemoveFromFavoritesSingleRecordAction';
|
import { useRemoveFromFavoritesSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useRemoveFromFavoritesSingleRecordAction';
|
||||||
@ -16,6 +17,7 @@ import {
|
|||||||
IconHeart,
|
IconHeart,
|
||||||
IconHeartOff,
|
IconHeartOff,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
|
IconTrashX,
|
||||||
} from 'twenty-ui';
|
} from 'twenty-ui';
|
||||||
|
|
||||||
export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
|
export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
|
||||||
@ -70,13 +72,29 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
|
|||||||
],
|
],
|
||||||
actionHook: useDeleteSingleRecordAction,
|
actionHook: useDeleteSingleRecordAction,
|
||||||
},
|
},
|
||||||
|
destroySingleRecord: {
|
||||||
|
type: ActionMenuEntryType.Standard,
|
||||||
|
scope: ActionMenuEntryScope.RecordSelection,
|
||||||
|
key: 'destroy-single-record',
|
||||||
|
label: 'Permanently destroy record',
|
||||||
|
shortLabel: 'Destroy',
|
||||||
|
position: 3,
|
||||||
|
Icon: IconTrashX,
|
||||||
|
accent: 'danger',
|
||||||
|
isPinned: true,
|
||||||
|
availableOn: [
|
||||||
|
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
|
||||||
|
ActionAvailableOn.SHOW_PAGE,
|
||||||
|
],
|
||||||
|
actionHook: useDestroySingleRecordAction,
|
||||||
|
},
|
||||||
navigateToPreviousRecord: {
|
navigateToPreviousRecord: {
|
||||||
type: ActionMenuEntryType.Standard,
|
type: ActionMenuEntryType.Standard,
|
||||||
scope: ActionMenuEntryScope.RecordSelection,
|
scope: ActionMenuEntryScope.RecordSelection,
|
||||||
key: 'navigate-to-previous-record',
|
key: 'navigate-to-previous-record',
|
||||||
label: 'Navigate to previous record',
|
label: 'Navigate to previous record',
|
||||||
shortLabel: '',
|
shortLabel: '',
|
||||||
position: 3,
|
position: 4,
|
||||||
isPinned: true,
|
isPinned: true,
|
||||||
Icon: IconChevronUp,
|
Icon: IconChevronUp,
|
||||||
availableOn: [ActionAvailableOn.SHOW_PAGE],
|
availableOn: [ActionAvailableOn.SHOW_PAGE],
|
||||||
@ -88,7 +106,7 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
|
|||||||
key: 'navigate-to-next-record',
|
key: 'navigate-to-next-record',
|
||||||
label: 'Navigate to next record',
|
label: 'Navigate to next record',
|
||||||
shortLabel: '',
|
shortLabel: '',
|
||||||
position: 4,
|
position: 5,
|
||||||
isPinned: true,
|
isPinned: true,
|
||||||
Icon: IconChevronDown,
|
Icon: IconChevronDown,
|
||||||
availableOn: [ActionAvailableOn.SHOW_PAGE],
|
availableOn: [ActionAvailableOn.SHOW_PAGE],
|
||||||
|
@ -2,6 +2,7 @@ import { SingleRecordActionHookWithObjectMetadataItem } from '@/action-menu/acti
|
|||||||
import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
|
import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
|
||||||
import { useFavorites } from '@/favorites/hooks/useFavorites';
|
import { useFavorites } from '@/favorites/hooks/useFavorites';
|
||||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||||
|
import { isNull } from '@sniptt/guards';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { isDefined } from 'twenty-ui';
|
import { isDefined } from 'twenty-ui';
|
||||||
|
|
||||||
@ -23,7 +24,8 @@ export const useAddToFavoritesSingleRecordAction: SingleRecordActionHookWithObje
|
|||||||
isDefined(objectMetadataItem) &&
|
isDefined(objectMetadataItem) &&
|
||||||
isDefined(selectedRecord) &&
|
isDefined(selectedRecord) &&
|
||||||
!objectMetadataItem.isRemote &&
|
!objectMetadataItem.isRemote &&
|
||||||
!isFavorite;
|
!isFavorite &&
|
||||||
|
isNull(selectedRecord.deletedAt);
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
if (!shouldBeRegistered) {
|
if (!shouldBeRegistered) {
|
||||||
|
@ -3,10 +3,13 @@ import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
|
|||||||
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
|
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
|
||||||
import { useFavorites } from '@/favorites/hooks/useFavorites';
|
import { useFavorites } from '@/favorites/hooks/useFavorites';
|
||||||
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
|
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
|
||||||
|
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||||
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
|
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
|
||||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||||
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
|
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
|
||||||
|
import { isNull } from '@sniptt/guards';
|
||||||
import { useCallback, useContext, useState } from 'react';
|
import { useCallback, useContext, useState } from 'react';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
import { isDefined } from 'twenty-ui';
|
import { isDefined } from 'twenty-ui';
|
||||||
|
|
||||||
export const useDeleteSingleRecordAction: SingleRecordActionHookWithObjectMetadataItem =
|
export const useDeleteSingleRecordAction: SingleRecordActionHookWithObjectMetadataItem =
|
||||||
@ -22,6 +25,8 @@ export const useDeleteSingleRecordAction: SingleRecordActionHookWithObjectMetada
|
|||||||
objectNameSingular: objectMetadataItem.nameSingular,
|
objectNameSingular: objectMetadataItem.nameSingular,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectedRecord = useRecoilValue(recordStoreFamilyState(recordId));
|
||||||
|
|
||||||
const { sortedFavorites: favorites } = useFavorites();
|
const { sortedFavorites: favorites } = useFavorites();
|
||||||
const { deleteFavorite } = useDeleteFavorite();
|
const { deleteFavorite } = useDeleteFavorite();
|
||||||
|
|
||||||
@ -52,7 +57,8 @@ export const useDeleteSingleRecordAction: SingleRecordActionHookWithObjectMetada
|
|||||||
const { isInRightDrawer, onActionExecutedCallback } =
|
const { isInRightDrawer, onActionExecutedCallback } =
|
||||||
useContext(ActionMenuContext);
|
useContext(ActionMenuContext);
|
||||||
|
|
||||||
const shouldBeRegistered = !isRemoteObject;
|
const shouldBeRegistered =
|
||||||
|
!isRemoteObject && isNull(selectedRecord?.deletedAt);
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
if (!shouldBeRegistered) {
|
if (!shouldBeRegistered) {
|
||||||
|
@ -0,0 +1,73 @@
|
|||||||
|
import { SingleRecordActionHookWithObjectMetadataItem } from '@/action-menu/actions/types/singleRecordActionHook';
|
||||||
|
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
|
||||||
|
import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord';
|
||||||
|
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||||
|
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
|
||||||
|
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||||
|
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
|
||||||
|
import { useCallback, useContext, useState } from 'react';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
|
import { isDefined } from 'twenty-ui';
|
||||||
|
|
||||||
|
export const useDestroySingleRecordAction: SingleRecordActionHookWithObjectMetadataItem =
|
||||||
|
({ recordId, objectMetadataItem }) => {
|
||||||
|
const [isDestroyRecordsModalOpen, setIsDestroyRecordsModalOpen] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
const { resetTableRowSelection } = useRecordTable({
|
||||||
|
recordTableId: objectMetadataItem.namePlural,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { destroyOneRecord } = useDestroyOneRecord({
|
||||||
|
objectNameSingular: objectMetadataItem.nameSingular,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedRecord = useRecoilValue(recordStoreFamilyState(recordId));
|
||||||
|
|
||||||
|
const { closeRightDrawer } = useRightDrawer();
|
||||||
|
|
||||||
|
const handleDeleteClick = useCallback(async () => {
|
||||||
|
resetTableRowSelection();
|
||||||
|
|
||||||
|
await destroyOneRecord(recordId);
|
||||||
|
}, [resetTableRowSelection, destroyOneRecord, recordId]);
|
||||||
|
|
||||||
|
const isRemoteObject = objectMetadataItem.isRemote;
|
||||||
|
|
||||||
|
const { isInRightDrawer, onActionExecutedCallback } =
|
||||||
|
useContext(ActionMenuContext);
|
||||||
|
|
||||||
|
const shouldBeRegistered =
|
||||||
|
!isRemoteObject && isDefined(selectedRecord?.deletedAt);
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
if (!shouldBeRegistered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDestroyRecordsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldBeRegistered,
|
||||||
|
onClick,
|
||||||
|
ConfirmationModal: (
|
||||||
|
<ConfirmationModal
|
||||||
|
isOpen={isDestroyRecordsModalOpen}
|
||||||
|
setIsOpen={setIsDestroyRecordsModalOpen}
|
||||||
|
title={'Permanently Destroy Record'}
|
||||||
|
subtitle={
|
||||||
|
'Are you sure you want to destroy this record? It cannot be recovered anymore.'
|
||||||
|
}
|
||||||
|
onConfirmClick={async () => {
|
||||||
|
await handleDeleteClick();
|
||||||
|
onActionExecutedCallback?.();
|
||||||
|
if (isInRightDrawer) {
|
||||||
|
closeRightDrawer();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
deleteButtonText={'Permanently Destroy Record'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
@ -27,7 +27,7 @@ export const RecordIndexActionMenuButtons = () => {
|
|||||||
size="small"
|
size="small"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
accent="default"
|
accent="default"
|
||||||
title={entry.label}
|
title={entry.shortLabel}
|
||||||
onClick={() => entry.onClick?.()}
|
onClick={() => entry.onClick?.()}
|
||||||
ariaLabel={entry.label}
|
ariaLabel={entry.label}
|
||||||
/>
|
/>
|
||||||
|
@ -39,6 +39,7 @@ export const useFindManyRecordsSelectedInContextStore = ({
|
|||||||
const { records, loading, totalCount } = useFindManyRecords({
|
const { records, loading, totalCount } = useFindManyRecords({
|
||||||
objectNameSingular: objectMetadataItem.nameSingular,
|
objectNameSingular: objectMetadataItem.nameSingular,
|
||||||
filter: queryFilter,
|
filter: queryFilter,
|
||||||
|
withSoftDeleted: true,
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{
|
{
|
||||||
position: 'AscNullsFirst',
|
position: 'AscNullsFirst',
|
||||||
|
@ -20,6 +20,7 @@ export type UseFindManyRecordsParams<T> = ObjectMetadataItemIdentifier &
|
|||||||
skip?: boolean;
|
skip?: boolean;
|
||||||
recordGqlFields?: RecordGqlOperationGqlRecordFields;
|
recordGqlFields?: RecordGqlOperationGqlRecordFields;
|
||||||
fetchPolicy?: WatchQueryFetchPolicy;
|
fetchPolicy?: WatchQueryFetchPolicy;
|
||||||
|
withSoftDeleted?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
|
export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
|
||||||
@ -33,6 +34,7 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
|
|||||||
onError,
|
onError,
|
||||||
onCompleted,
|
onCompleted,
|
||||||
cursorFilter,
|
cursorFilter,
|
||||||
|
withSoftDeleted = false,
|
||||||
}: UseFindManyRecordsParams<T>) => {
|
}: UseFindManyRecordsParams<T>) => {
|
||||||
const { objectMetadataItem } = useObjectMetadataItem({
|
const { objectMetadataItem } = useObjectMetadataItem({
|
||||||
objectNameSingular,
|
objectNameSingular,
|
||||||
@ -61,11 +63,18 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
|
|||||||
onCompleted,
|
onCompleted,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const withSoftDeleterFilter = {
|
||||||
|
or: [{ deletedAt: { is: 'NULL' } }, { deletedAt: { is: 'NOT_NULL' } }],
|
||||||
|
};
|
||||||
|
|
||||||
const { data, loading, error, fetchMore } =
|
const { data, loading, error, fetchMore } =
|
||||||
useQuery<RecordGqlOperationFindManyResult>(findManyRecordsQuery, {
|
useQuery<RecordGqlOperationFindManyResult>(findManyRecordsQuery, {
|
||||||
skip: skip || !objectMetadataItem,
|
skip: skip || !objectMetadataItem,
|
||||||
variables: {
|
variables: {
|
||||||
filter,
|
filter: {
|
||||||
|
...filter,
|
||||||
|
...(withSoftDeleted ? withSoftDeleterFilter : {}),
|
||||||
|
},
|
||||||
orderBy,
|
orderBy,
|
||||||
lastCursor: cursorFilter?.cursor ?? undefined,
|
lastCursor: cursorFilter?.cursor ?? undefined,
|
||||||
limit: cursorFilter?.limit ?? limit,
|
limit: cursorFilter?.limit ?? limit,
|
||||||
|
@ -45,6 +45,7 @@ export const peopleQueryResult: { people: RecordGqlConnection } = {
|
|||||||
__typename: 'Person',
|
__typename: 'Person',
|
||||||
createdAt: '2024-08-02T09:52:46.814Z',
|
createdAt: '2024-08-02T09:52:46.814Z',
|
||||||
city: 'ASd',
|
city: 'ASd',
|
||||||
|
deletedAt: null,
|
||||||
phones: {
|
phones: {
|
||||||
primaryPhoneNumber: '781234562',
|
primaryPhoneNumber: '781234562',
|
||||||
primaryPhoneCountryCode: 'FR',
|
primaryPhoneCountryCode: 'FR',
|
||||||
|
@ -244,6 +244,7 @@ export {
|
|||||||
IconTextWrap,
|
IconTextWrap,
|
||||||
IconTimelineEvent,
|
IconTimelineEvent,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
|
IconTrashX,
|
||||||
IconUnlink,
|
IconUnlink,
|
||||||
IconUpload,
|
IconUpload,
|
||||||
IconUser,
|
IconUser,
|
||||||
|
@ -117,7 +117,7 @@ const StyledButton = styled.button<
|
|||||||
border-color: ${variant === 'secondary'
|
border-color: ${variant === 'secondary'
|
||||||
? !disabled && focus
|
? !disabled && focus
|
||||||
? theme.color.blue
|
? theme.color.blue
|
||||||
: theme.background.transparent.light
|
: theme.background.transparent.medium
|
||||||
: focus
|
: focus
|
||||||
? theme.color.blue
|
? theme.color.blue
|
||||||
: 'transparent'};
|
: 'transparent'};
|
||||||
|
Loading…
Reference in New Issue
Block a user