mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-16 21:03:29 +00:00
Swap Event Data (#446)
* use zustand store in `ContextMenuContext` * add `withDivider` prop to context menu `Option` * add `isDisabled` prop to `Option` * create `eventIdSwapping` store * create swapping context menu options * fix `key` in ContextMenu * address `tanstack-eslint` errors * create initial `requestEventSwap` event * create initial `swapEvents` action * use `swapEvents` in `EventBlock` * move from `emitError` to `logAxiosError` * remove extra curly brace from Copy ID * add `clearEventId` func * finalize context menu swapping logic * write optimistic swapping logic * remove unused import and type out handler in `rundownController` * create `rundownSwapValidator` * move index increase to `EventBlock` * move swapping logic from `id` to `index` * create `swapEvents` endpoint * move `useEventIdSwapping` hook into its own file * revert to using event `id` * logic now uses indexes to swap events * add `todo` to `swapEvents` * revert index increment * create `swapOntimeEvents` and export in `index.ts` * use `swapOntimeEvents` in frontend & server * update import path * remove extra `setCached`
This commit is contained in:
+12
-12
@@ -4,9 +4,9 @@ import { ChakraProvider } from '@chakra-ui/react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
import { ContextMenu } from './common/components/context-menu/ContextMenu';
|
||||
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
|
||||
import { AppContextProvider } from './common/context/AppContext';
|
||||
import { ContextMenuProvider } from './common/context/ContextMenuContext';
|
||||
import useElectronEvent from './common/hooks/useElectronEvent';
|
||||
import { ontimeQueryClient } from './common/queryClient';
|
||||
import { socketClientName } from './common/stores/connectionName';
|
||||
@@ -52,18 +52,18 @@ function App() {
|
||||
<ChakraProvider resetCSS theme={theme}>
|
||||
<QueryClientProvider client={ontimeQueryClient}>
|
||||
<AppContextProvider>
|
||||
<ContextMenuProvider>
|
||||
<BrowserRouter>
|
||||
<div className='App'>
|
||||
<ErrorBoundary>
|
||||
<TranslationProvider>
|
||||
<BrowserRouter>
|
||||
<div className='App'>
|
||||
<ErrorBoundary>
|
||||
<TranslationProvider>
|
||||
<ContextMenu>
|
||||
<AppRouter />
|
||||
</TranslationProvider>
|
||||
</ErrorBoundary>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</ContextMenuProvider>
|
||||
</ContextMenu>
|
||||
</TranslationProvider>
|
||||
</ErrorBoundary>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</AppContextProvider>
|
||||
</QueryClientProvider>
|
||||
</ChakraProvider>
|
||||
|
||||
@@ -50,6 +50,19 @@ export async function requestApplyDelay(eventId: string) {
|
||||
return axios.patch(`${rundownURL}/applydelay/${eventId}`);
|
||||
}
|
||||
|
||||
export type SwapEntry = {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description HTTP request to swap two events
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function requestEventSwap(data: SwapEntry) {
|
||||
return axios.patch(`${rundownURL}/swap`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to delete given event
|
||||
* @return {Promise}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// logic (with some modifications) culled from:
|
||||
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
|
||||
|
||||
import { Fragment, ReactElement } from 'react';
|
||||
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
|
||||
import { IconType } from '@react-icons/all-files';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import style from './ContextMenu.module.scss';
|
||||
|
||||
type ContextMenuCoords = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type Option = {
|
||||
label: string;
|
||||
icon: IconType;
|
||||
onClick: () => void;
|
||||
withDivider?: boolean;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
type ContextMenuStore = {
|
||||
coords: ContextMenuCoords;
|
||||
options: Option[];
|
||||
isOpen: boolean;
|
||||
setContextMenu: (coords: ContextMenuCoords, options: Option[]) => void;
|
||||
setIsOpen: (newIsOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const useContextMenuStore = create<ContextMenuStore>((set) => ({
|
||||
coords: { x: 0, y: 0 },
|
||||
options: [],
|
||||
isOpen: false,
|
||||
setContextMenu: (coords, options) => set(() => ({ coords, options, isOpen: true })),
|
||||
setIsOpen: (newIsOpen) => set(() => ({ isOpen: newIsOpen })),
|
||||
}));
|
||||
|
||||
interface ContextMenuProps {
|
||||
// ReactElement type required due to early `return` (line 51) returning {children}
|
||||
children: ReactElement;
|
||||
}
|
||||
|
||||
export const ContextMenu = ({ children }: ContextMenuProps) => {
|
||||
const { coords, options, isOpen, setIsOpen } = useContextMenuStore();
|
||||
|
||||
const onClose = () => {
|
||||
return setIsOpen(false);
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<div className={style.contextMenuBackdrop} />
|
||||
<Menu isOpen gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
|
||||
<MenuButton
|
||||
className={style.contextMenuButton}
|
||||
aria-hidden
|
||||
w={1}
|
||||
h={1}
|
||||
style={{
|
||||
left: coords.x,
|
||||
top: coords.y,
|
||||
}}
|
||||
/>
|
||||
<MenuList>
|
||||
{options.map(({ label, icon: Icon, onClick, withDivider, isDisabled }, i) => (
|
||||
<Fragment key={label}>
|
||||
{withDivider && <MenuDivider />}
|
||||
<MenuItem key={i} icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
// logic (with some modifications) culled from:
|
||||
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
|
||||
|
||||
import { createContext, ReactNode, useState } from 'react';
|
||||
import { Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
|
||||
import { IconType } from '@react-icons/all-files';
|
||||
|
||||
import style from './ContextMenuContext.module.scss';
|
||||
|
||||
type ContextMenuCoords = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type ContextMenuContextType = {
|
||||
createContextMenu: (options: Option[], menuCoordinates: ContextMenuCoords) => void;
|
||||
};
|
||||
|
||||
export const ContextMenuContext = createContext<ContextMenuContextType | null>(null);
|
||||
|
||||
export type Option = {
|
||||
label: string;
|
||||
icon: IconType;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
interface ContextMenuProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const [coords, setCoords] = useState<ContextMenuCoords>({ x: 0, y: 0 });
|
||||
const [options, setOptions] = useState<Option[]>([]);
|
||||
|
||||
const onClose = () => {
|
||||
return setIsOpen(false);
|
||||
};
|
||||
|
||||
const createContextMenu = (options: Option[], menuCoords: ContextMenuCoords) => {
|
||||
setCoords(menuCoords);
|
||||
setOptions(options);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenuContext.Provider value={{ createContextMenu }}>
|
||||
{children}
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className={style.contextMenuBackdrop} />
|
||||
<Menu isOpen gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
|
||||
<MenuButton
|
||||
className={style.contextMenuButton}
|
||||
aria-hidden
|
||||
w={1}
|
||||
h={1}
|
||||
style={{
|
||||
left: coords.x,
|
||||
top: coords.y,
|
||||
}}
|
||||
/>
|
||||
<MenuList>
|
||||
{options.map(({ label, icon: Icon, onClick }, i) => (
|
||||
<MenuItem key={i} icon={<Icon />} onClick={onClick}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +1,16 @@
|
||||
import { MouseEvent, useContext } from 'react';
|
||||
import { MouseEvent } from 'react';
|
||||
|
||||
import { ContextMenuContext, Option } from '../context/ContextMenuContext';
|
||||
import { Option, useContextMenuStore } from '../components/context-menu/ContextMenu';
|
||||
|
||||
export const useContextMenu = <T extends HTMLElement>(options: Option[]) => {
|
||||
const contextMenuContext = useContext(ContextMenuContext);
|
||||
|
||||
if (contextMenuContext === null) {
|
||||
throw new Error('useContextMenu should be wrapped by ContextMenuProvider');
|
||||
}
|
||||
|
||||
const { createContextMenu } = contextMenuContext;
|
||||
const { setContextMenu } = useContextMenuStore();
|
||||
|
||||
const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
|
||||
// prevent browser default context menu from showing up
|
||||
contextMenuEvent.preventDefault();
|
||||
|
||||
const { pageX, pageY } = contextMenuEvent;
|
||||
return createContextMenu(options, { x: pageX, y: pageY });
|
||||
return setContextMenu({ x: pageX, y: pageY }, options);
|
||||
};
|
||||
|
||||
return [localCreateContextMenu];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
|
||||
import { swapOntimeEvents } from 'ontime-utils';
|
||||
|
||||
import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants';
|
||||
import { logAxiosError } from '../api/apiUtils';
|
||||
@@ -9,9 +10,11 @@ import {
|
||||
requestApplyDelay,
|
||||
requestDelete,
|
||||
requestDeleteAll,
|
||||
requestEventSwap,
|
||||
requestPostEvent,
|
||||
requestPutEvent,
|
||||
requestReorderEvent,
|
||||
SwapEntry,
|
||||
} from '../api/eventsApi';
|
||||
import { useEditorSettings } from '../stores/editorSettings';
|
||||
|
||||
@@ -28,9 +31,10 @@ export const useEventAction = () => {
|
||||
* Calls mutation to add new event
|
||||
* @private
|
||||
*/
|
||||
const _addEventMutation = useMutation(requestPostEvent, {
|
||||
const _addEventMutation = useMutation({
|
||||
// Mutation finished, failed or successful
|
||||
// Fetch anyway, just to be sure
|
||||
mutationFn: requestPostEvent,
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||
},
|
||||
@@ -102,7 +106,8 @@ export const useEventAction = () => {
|
||||
* Calls mutation to update existing event
|
||||
* @private
|
||||
*/
|
||||
const _updateEventMutation = useMutation(requestPutEvent, {
|
||||
const _updateEventMutation = useMutation({
|
||||
mutationFn: requestPutEvent,
|
||||
// we optimistically update here
|
||||
onMutate: async (newEvent) => {
|
||||
// cancel ongoing queries
|
||||
@@ -117,11 +122,11 @@ export const useEventAction = () => {
|
||||
// Return a context with the previous and new events
|
||||
return { previousEvent, newEvent };
|
||||
},
|
||||
|
||||
// Mutation fails, rollback undoes optimist update
|
||||
onError: (_error, _newEvent, context) => {
|
||||
queryClient.setQueryData([RUNDOWN_TABLE_KEY, context?.newEvent.id], context?.previousEvent);
|
||||
},
|
||||
|
||||
// Mutation finished, failed or successful
|
||||
// Fetch anyway, just to be sure
|
||||
onSettled: async () => {
|
||||
@@ -148,7 +153,8 @@ export const useEventAction = () => {
|
||||
* Calls mutation to delete an event
|
||||
* @private
|
||||
*/
|
||||
const _deleteEventMutation = useMutation(requestDelete, {
|
||||
const _deleteEventMutation = useMutation({
|
||||
mutationFn: requestDelete,
|
||||
// we optimistically update here
|
||||
onMutate: async (eventId) => {
|
||||
// cancel ongoing queries
|
||||
@@ -196,7 +202,8 @@ export const useEventAction = () => {
|
||||
* Calls mutation to delete all events
|
||||
* @private
|
||||
*/
|
||||
const _deleteAllEventsMutation = useMutation(requestDeleteAll, {
|
||||
const _deleteAllEventsMutation = useMutation({
|
||||
mutationFn: requestDeleteAll,
|
||||
// we optimistically update here
|
||||
onMutate: async () => {
|
||||
// cancel ongoing queries
|
||||
@@ -239,7 +246,8 @@ export const useEventAction = () => {
|
||||
* Calls mutation to apply a delay
|
||||
* @private
|
||||
*/
|
||||
const _applyDelayMutation = useMutation(requestApplyDelay, {
|
||||
const _applyDelayMutation = useMutation({
|
||||
mutationFn: requestApplyDelay,
|
||||
// Mutation finished, failed or successful
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||
@@ -265,7 +273,8 @@ export const useEventAction = () => {
|
||||
* Calls mutation to reorder an event
|
||||
* @private
|
||||
*/
|
||||
const _reorderEventMutation = useMutation(requestReorderEvent, {
|
||||
const _reorderEventMutation = useMutation({
|
||||
mutationFn: requestReorderEvent,
|
||||
// we optimistically update here
|
||||
onMutate: async (data) => {
|
||||
// cancel ongoing queries
|
||||
@@ -316,5 +325,67 @@ export const useEventAction = () => {
|
||||
[_reorderEventMutation],
|
||||
);
|
||||
|
||||
return { addEvent, updateEvent, deleteEvent, deleteAllEvents, applyDelay, reorderEvent };
|
||||
/**
|
||||
* Calls mutation to swap events
|
||||
* @private
|
||||
*/
|
||||
const _swapEvents = useMutation({
|
||||
mutationFn: requestEventSwap,
|
||||
// we optimistically update here
|
||||
onMutate: async ({ from, to }) => {
|
||||
// cancel ongoing queries
|
||||
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
|
||||
|
||||
// Snapshot the previous value
|
||||
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
|
||||
|
||||
const fromEventIndex = rundown.findIndex((event) => event.id === from);
|
||||
const toEventIndex = rundown.findIndex((event) => event.id === to);
|
||||
|
||||
const previousEvents = swapOntimeEvents(rundown, fromEventIndex, toEventIndex);
|
||||
|
||||
// optimistically update object
|
||||
queryClient.setQueryData(RUNDOWN_TABLE, previousEvents);
|
||||
|
||||
// Return a context with the previous events
|
||||
return { previousEvents };
|
||||
},
|
||||
|
||||
// Mutation fails, rollback undoes optimist update
|
||||
onError: (_error, _eventId, context) => {
|
||||
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
|
||||
},
|
||||
// Mutation finished, failed or successful
|
||||
// Fetch anyway, just to be sure
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||
},
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
/**
|
||||
* Swaps the schedule of two events
|
||||
*/
|
||||
const swapEvents = useCallback(
|
||||
async ({ from, to }: SwapEntry) => {
|
||||
// TODO: before calling `/swapEvents`,
|
||||
// we should determine the events are of type `OntimeEvent`
|
||||
try {
|
||||
await _swapEvents.mutateAsync({ from, to });
|
||||
} catch (error) {
|
||||
logAxiosError('Error re-ordering event', error);
|
||||
}
|
||||
},
|
||||
[_swapEvents],
|
||||
);
|
||||
|
||||
return {
|
||||
addEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
deleteAllEvents,
|
||||
applyDelay,
|
||||
reorderEvent,
|
||||
swapEvents,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ import BlockBlock from './block-block/BlockBlock';
|
||||
import DelayBlock from './delay-block/DelayBlock';
|
||||
import EventBlock from './event-block/EventBlock';
|
||||
|
||||
export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'delete' | 'clone' | 'update';
|
||||
export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'delete' | 'clone' | 'update' | 'swap';
|
||||
|
||||
interface RundownEntryProps {
|
||||
type: SupportedEvent;
|
||||
@@ -44,7 +44,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
disableEdit,
|
||||
} = props;
|
||||
const { emitError } = useEmitLog();
|
||||
const { addEvent, updateEvent, deleteEvent } = useEventAction();
|
||||
const { addEvent, updateEvent, deleteEvent, swapEvents } = useEventAction();
|
||||
|
||||
const cursor = useAppMode((state) => state.cursor);
|
||||
const setCursor = useAppMode((state) => state.setCursor);
|
||||
@@ -95,6 +95,12 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
addEvent({ type: SupportedEvent.Block }, { after: data.id });
|
||||
break;
|
||||
}
|
||||
case 'swap': {
|
||||
const { value } = payload as FieldValue;
|
||||
swapEvents({ from: value as string, to: data.id });
|
||||
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
if (openId === data.id) {
|
||||
removeOpenEvent();
|
||||
@@ -149,6 +155,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
removeOpenEvent,
|
||||
startTimeIsLastEnd,
|
||||
updateEvent,
|
||||
swapEvents,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoCopyOutline } from '@react-icons/all-files/io5/IoCopyOutline';
|
||||
import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
|
||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
import { EndAction, OntimeEvent, Playback, TimerType } from 'ontime-types';
|
||||
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { useAppMode } from '../../../common/stores/appModeStore';
|
||||
import copyToClipboard from '../../../common/utils/copyToClipboard';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import type { EventItemActions } from '../RundownEntry';
|
||||
import { useEventIdSwapping } from '../useEventIdSwapping';
|
||||
|
||||
import EventBlockInner from './EventBlockInner';
|
||||
|
||||
@@ -75,22 +77,37 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
actionHandler,
|
||||
disableEdit,
|
||||
} = props;
|
||||
const { updateEvent } = useEventAction();
|
||||
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
|
||||
const moveCursorTo = useAppMode((state) => state.setCursor);
|
||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const openId = useAppMode((state) => state.editId);
|
||||
const [onContextMenu] = useContextMenu<HTMLDivElement>([
|
||||
{ label: `Copy ID: ${eventId}}`, icon: IoCopyOutline, onClick: () => copyToClipboard(eventId) },
|
||||
{ label: `Copy ID: ${eventId}`, icon: IoCopyOutline, onClick: () => copyToClipboard(eventId) },
|
||||
{
|
||||
label: 'Toggle public',
|
||||
icon: IoPeopleOutline,
|
||||
onClick: () =>
|
||||
updateEvent({
|
||||
id: eventId,
|
||||
isPublic: !isPublic,
|
||||
actionHandler('update', {
|
||||
field: 'isPublic',
|
||||
value: !isPublic,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Add to swap',
|
||||
icon: IoAdd,
|
||||
onClick: () => setSelectedEventId(eventId),
|
||||
withDivider: true,
|
||||
},
|
||||
{
|
||||
label: `Swap this event with ${selectedEventId ?? ''}`,
|
||||
icon: IoSwapVertical,
|
||||
onClick: () => {
|
||||
actionHandler('swap', { field: 'id', value: selectedEventId });
|
||||
clearSelectedEventId();
|
||||
},
|
||||
isDisabled: selectedEventId == null || selectedEventId === eventId,
|
||||
},
|
||||
]);
|
||||
|
||||
const {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface EventIdSwappingStore {
|
||||
selectedEventId: string | null;
|
||||
setSelectedEventId: (newEventId: string | null) => void;
|
||||
clearSelectedEventId: () => void;
|
||||
}
|
||||
|
||||
export const useEventIdSwapping = create<EventIdSwappingStore>((set) => ({
|
||||
selectedEventId: null,
|
||||
setSelectedEventId: (newEventId) => set(() => ({ selectedEventId: newEventId })),
|
||||
clearSelectedEventId: () => set(() => ({ selectedEventId: null })),
|
||||
}));
|
||||
Reference in New Issue
Block a user