Allow adding songs to multiple playlists at once. (#995)
* Add support for multiple playlists * Fix lint * Remove console log comment * Disable 'check' when loading * Fix lint * reset playlists on closeAddToPlaylist * new playlist: accomodate string type on enter * Fix lint * multiple new playlists are added correctly * use makestyle() * Add tests * Fix lint
This commit is contained in:
@@ -1,11 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import {
|
||||
useCreate,
|
||||
useDataProvider,
|
||||
useNotify,
|
||||
useTranslate,
|
||||
} from 'react-admin'
|
||||
import { useDataProvider, useNotify, useTranslate } from 'react-admin'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
@@ -19,8 +14,6 @@ import {
|
||||
openDuplicateSongWarning,
|
||||
} from '../actions'
|
||||
import { SelectPlaylistInput } from './SelectPlaylistInput'
|
||||
import { httpClient } from '../dataProvider'
|
||||
import { REST_URL } from '../consts'
|
||||
import DuplicateSongDialog from './DuplicateSongDialog'
|
||||
|
||||
export const AddToPlaylistDialog = () => {
|
||||
@@ -37,17 +30,16 @@ export const AddToPlaylistDialog = () => {
|
||||
const [value, setValue] = useState({})
|
||||
const [check, setCheck] = useState(false)
|
||||
const dataProvider = useDataProvider()
|
||||
const [createAndAddToPlaylist] = useCreate(
|
||||
'playlist',
|
||||
{ name: value.name },
|
||||
{
|
||||
onSuccess: ({ data }) => {
|
||||
setValue(data)
|
||||
addToPlaylist(data.id)
|
||||
},
|
||||
onFailure: (error) => notify(`Error: ${error.message}`, 'warning'),
|
||||
}
|
||||
)
|
||||
const createAndAddToPlaylist = (playlistObject) => {
|
||||
dataProvider
|
||||
.create('playlist', {
|
||||
data: { name: playlistObject.name },
|
||||
})
|
||||
.then((res) => {
|
||||
addToPlaylist(res.data.id)
|
||||
})
|
||||
.catch((error) => notify(`Error: ${error.message}`, 'warning'))
|
||||
}
|
||||
|
||||
const addToPlaylist = (playlistId, distinctIds) => {
|
||||
const trackIds = Array.isArray(distinctIds) ? distinctIds : selectedIds
|
||||
@@ -66,10 +58,11 @@ export const AddToPlaylistDialog = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const checkDuplicateSong = (playlistId) => {
|
||||
httpClient(`${REST_URL}/playlist/${playlistId}`)
|
||||
const checkDuplicateSong = (playlistObject) => {
|
||||
dataProvider
|
||||
.getOne('playlist', { id: playlistObject.id })
|
||||
.then((res) => {
|
||||
const { tracks } = JSON.parse(res.body)
|
||||
const tracks = res.data.tracks
|
||||
if (tracks) {
|
||||
const dupSng = tracks.filter((song) =>
|
||||
selectedIds.some((id) => id === song.id)
|
||||
@@ -77,11 +70,9 @@ export const AddToPlaylistDialog = () => {
|
||||
|
||||
if (dupSng.length) {
|
||||
const dupIds = dupSng.map((song) => song.id)
|
||||
return dispatch(openDuplicateSongWarning(dupIds))
|
||||
dispatch(openDuplicateSongWarning(dupIds))
|
||||
}
|
||||
return setCheck(true)
|
||||
}
|
||||
|
||||
setCheck(true)
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -91,47 +82,49 @@ export const AddToPlaylistDialog = () => {
|
||||
}
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
if (value.id) {
|
||||
addToPlaylist(value.id)
|
||||
} else {
|
||||
createAndAddToPlaylist()
|
||||
}
|
||||
value.forEach((playlistObject) => {
|
||||
if (playlistObject.id) {
|
||||
addToPlaylist(playlistObject.id, playlistObject.distinctIds)
|
||||
} else {
|
||||
createAndAddToPlaylist(playlistObject)
|
||||
}
|
||||
})
|
||||
setCheck(false)
|
||||
setValue({})
|
||||
dispatch(closeAddToPlaylist())
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleClickClose = (e) => {
|
||||
setCheck(false)
|
||||
setValue({})
|
||||
dispatch(closeAddToPlaylist())
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleChange = (pls) => {
|
||||
if (pls.id) {
|
||||
checkDuplicateSong(pls.id)
|
||||
} else {
|
||||
setCheck(true)
|
||||
}
|
||||
if (!value.length || pls.length > value.length) {
|
||||
let newlyAdded = pls.slice(-1).pop()
|
||||
if (newlyAdded.id) {
|
||||
setCheck(false)
|
||||
checkDuplicateSong(newlyAdded)
|
||||
} else setCheck(true)
|
||||
} else if (pls.length === 0) setCheck(false)
|
||||
setValue(pls)
|
||||
}
|
||||
|
||||
const handleDuplicateClose = () => {
|
||||
dispatch(closeDuplicateSongDialog())
|
||||
dispatch(closeAddToPlaylist())
|
||||
}
|
||||
const handleDuplicateSubmit = () => {
|
||||
addToPlaylist(value.id)
|
||||
dispatch(closeDuplicateSongDialog())
|
||||
dispatch(closeAddToPlaylist())
|
||||
}
|
||||
const handleSkip = () => {
|
||||
const distinctSongs = selectedIds.filter(
|
||||
(id) => duplicateIds.indexOf(id) < 0
|
||||
)
|
||||
addToPlaylist(value.id, distinctSongs)
|
||||
value.slice(-1).pop().distinctIds = distinctSongs
|
||||
dispatch(closeDuplicateSongDialog())
|
||||
dispatch(closeAddToPlaylist())
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -154,7 +147,12 @@ export const AddToPlaylistDialog = () => {
|
||||
<Button onClick={handleClickClose} color="primary">
|
||||
{translate('ra.action.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} color="primary" disabled={!check}>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
color="primary"
|
||||
disabled={!check}
|
||||
data-testid="playlist-add"
|
||||
>
|
||||
{translate('ra.action.add')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import * as React from 'react'
|
||||
import { TestContext } from 'ra-test'
|
||||
import { DataProviderContext } from 'react-admin'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { AddToPlaylistDialog } from './AddToPlaylistDialog'
|
||||
|
||||
describe('AddToPlaylistDialog', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
let mockData = [
|
||||
{ id: 'sample-id1', name: 'sample playlist 1', owner: 'admin' },
|
||||
{ id: 'sample-id2', name: 'sample playlist 2', owner: 'admin' },
|
||||
]
|
||||
let mockIndexedData = {
|
||||
'sample-id1': {
|
||||
id: 'sample-id1',
|
||||
name: 'sample playlist 1',
|
||||
owner: 'admin',
|
||||
},
|
||||
'sample-id2': {
|
||||
id: 'sample-id2',
|
||||
name: 'sample playlist 2',
|
||||
owner: 'admin',
|
||||
},
|
||||
}
|
||||
let selectedIds = ['song-1', 'song-2']
|
||||
|
||||
it('adds distinct songs to already existing playlists', async () => {
|
||||
let mockDataProvider = {
|
||||
getList: jest.fn(() =>
|
||||
Promise.resolve({ data: mockData, total: mockData.length })
|
||||
),
|
||||
getOne: jest.fn(() =>
|
||||
Promise.resolve({ data: { id: 'song-3' }, total: 1 })
|
||||
),
|
||||
create: jest.fn(() =>
|
||||
Promise.resolve({ data: { id: 'created-id', name: 'created-name' } })
|
||||
),
|
||||
}
|
||||
const testutils = render(
|
||||
<DataProviderContext.Provider value={mockDataProvider}>
|
||||
<TestContext
|
||||
initialState={{
|
||||
addToPlaylistDialog: {
|
||||
open: true,
|
||||
duplicateSong: false,
|
||||
selectedIds: selectedIds,
|
||||
},
|
||||
admin: {
|
||||
ui: { optimistic: false },
|
||||
resources: {
|
||||
playlist: {
|
||||
data: mockIndexedData,
|
||||
list: {
|
||||
cachedRequests: {
|
||||
'{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{}}': {
|
||||
ids: ['sample-id1', 'sample-id2'],
|
||||
total: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AddToPlaylistDialog />
|
||||
</TestContext>
|
||||
</DataProviderContext.Provider>
|
||||
)
|
||||
|
||||
fireEvent.change(document.activeElement, { target: { value: 'sample' } })
|
||||
fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(document.activeElement, { key: 'Enter' })
|
||||
fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(document.activeElement, { key: 'Enter' })
|
||||
await waitFor(() => {
|
||||
expect(testutils.getByTestId('playlist-add')).not.toBeDisabled()
|
||||
})
|
||||
fireEvent.click(testutils.getByTestId('playlist-add'))
|
||||
await waitFor(() => {
|
||||
expect(mockDataProvider.create).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'playlistTrack',
|
||||
{
|
||||
data: { ids: selectedIds },
|
||||
filter: { playlist_id: 'sample-id1' },
|
||||
}
|
||||
)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(mockDataProvider.create).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'playlistTrack',
|
||||
{
|
||||
data: { ids: selectedIds },
|
||||
filter: { playlist_id: 'sample-id2' },
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
let mockDataProvider = {
|
||||
getList: jest.fn(() =>
|
||||
Promise.resolve({ data: mockData, total: mockData.length })
|
||||
),
|
||||
getOne: jest.fn(() =>
|
||||
Promise.resolve({ data: { id: 'song-3' }, total: 1 })
|
||||
),
|
||||
create: jest.fn(() =>
|
||||
Promise.resolve({ data: { id: 'created-id1', name: 'created-name' } })
|
||||
),
|
||||
}
|
||||
|
||||
it('adds distinct songs to a new playlist', async () => {
|
||||
const testutils = render(
|
||||
<DataProviderContext.Provider value={mockDataProvider}>
|
||||
<TestContext
|
||||
initialState={{
|
||||
addToPlaylistDialog: {
|
||||
open: true,
|
||||
duplicateSong: false,
|
||||
selectedIds: selectedIds,
|
||||
},
|
||||
admin: {
|
||||
ui: { optimistic: false },
|
||||
resources: {
|
||||
playlist: {
|
||||
data: mockIndexedData,
|
||||
list: {
|
||||
cachedRequests: {
|
||||
'{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{}}': {
|
||||
ids: ['sample-id1', 'sample-id2'],
|
||||
total: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AddToPlaylistDialog />
|
||||
</TestContext>
|
||||
</DataProviderContext.Provider>
|
||||
)
|
||||
|
||||
fireEvent.change(document.activeElement, { target: { value: 'sample' } })
|
||||
fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(document.activeElement, { key: 'Enter' })
|
||||
await waitFor(() => {
|
||||
expect(testutils.getByTestId('playlist-add')).not.toBeDisabled()
|
||||
})
|
||||
fireEvent.click(testutils.getByTestId('playlist-add'))
|
||||
await waitFor(() => {
|
||||
expect(mockDataProvider.create).toHaveBeenNthCalledWith(1, 'playlist', {
|
||||
data: { name: 'sample' },
|
||||
})
|
||||
expect(mockDataProvider.create).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'playlistTrack',
|
||||
{
|
||||
data: { ids: selectedIds },
|
||||
filter: { playlist_id: 'created-id1' },
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('adds distinct songs to multiple new playlists', async () => {
|
||||
const testutils = render(
|
||||
<DataProviderContext.Provider value={mockDataProvider}>
|
||||
<TestContext
|
||||
initialState={{
|
||||
addToPlaylistDialog: {
|
||||
open: true,
|
||||
duplicateSong: false,
|
||||
selectedIds: selectedIds,
|
||||
},
|
||||
admin: {
|
||||
ui: { optimistic: false },
|
||||
resources: {
|
||||
playlist: {
|
||||
data: mockIndexedData,
|
||||
list: {
|
||||
cachedRequests: {
|
||||
'{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{}}': {
|
||||
ids: ['sample-id1', 'sample-id2'],
|
||||
total: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AddToPlaylistDialog />
|
||||
</TestContext>
|
||||
</DataProviderContext.Provider>
|
||||
)
|
||||
|
||||
fireEvent.change(document.activeElement, { target: { value: 'sample' } })
|
||||
fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(document.activeElement, { key: 'Enter' })
|
||||
fireEvent.change(document.activeElement, {
|
||||
target: { value: 'new playlist' },
|
||||
})
|
||||
fireEvent.keyDown(document.activeElement, { key: 'Enter' })
|
||||
await waitFor(() => {
|
||||
expect(testutils.getByTestId('playlist-add')).not.toBeDisabled()
|
||||
})
|
||||
fireEvent.click(testutils.getByTestId('playlist-add'))
|
||||
await waitFor(() => {
|
||||
expect(mockDataProvider.create).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,8 @@
|
||||
import React from 'react'
|
||||
import TextField from '@material-ui/core/TextField'
|
||||
import Checkbox from '@material-ui/core/Checkbox'
|
||||
import CheckBoxIcon from '@material-ui/icons/CheckBox'
|
||||
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'
|
||||
import Autocomplete, {
|
||||
createFilterOptions,
|
||||
} from '@material-ui/lab/Autocomplete'
|
||||
@@ -12,6 +15,7 @@ const filter = createFilterOptions()
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { width: '100%' },
|
||||
checkbox: { marginRight: 8 },
|
||||
})
|
||||
|
||||
export const SelectPlaylistInput = ({ onChange }) => {
|
||||
@@ -29,24 +33,32 @@ export const SelectPlaylistInput = ({ onChange }) => {
|
||||
ids.map((id) => data[id]).filter((option) => isWritable(option.owner))
|
||||
|
||||
const handleOnChange = (event, newValue) => {
|
||||
if (newValue == null) {
|
||||
onChange({})
|
||||
} else if (typeof newValue === 'string') {
|
||||
onChange({
|
||||
name: newValue,
|
||||
let newState = []
|
||||
if (newValue && newValue.length) {
|
||||
newValue.forEach((playlistObject) => {
|
||||
if (playlistObject.inputValue) {
|
||||
newState.push({
|
||||
name: playlistObject.inputValue,
|
||||
})
|
||||
} else if (typeof playlistObject === 'string') {
|
||||
newState.push({
|
||||
name: playlistObject,
|
||||
})
|
||||
} else {
|
||||
newState.push(playlistObject)
|
||||
}
|
||||
})
|
||||
} else if (newValue && newValue.inputValue) {
|
||||
// Create a new value from the user input
|
||||
onChange({
|
||||
name: newValue.inputValue,
|
||||
})
|
||||
} else {
|
||||
onChange(newValue)
|
||||
}
|
||||
onChange(newState)
|
||||
}
|
||||
|
||||
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />
|
||||
const checkedIcon = <CheckBoxIcon fontSize="small" />
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
multiple
|
||||
disableCloseOnSelect
|
||||
onChange={handleOnChange}
|
||||
filterOptions={(options, params) => {
|
||||
const filtered = filter(options, params)
|
||||
@@ -81,7 +93,17 @@ export const SelectPlaylistInput = ({ onChange }) => {
|
||||
// Regular option
|
||||
return option.name
|
||||
}}
|
||||
renderOption={(option) => option.name}
|
||||
renderOption={(option, { selected }) => (
|
||||
<React.Fragment>
|
||||
<Checkbox
|
||||
icon={icon}
|
||||
checkedIcon={checkedIcon}
|
||||
className={classes.checkbox}
|
||||
checked={selected}
|
||||
/>
|
||||
{option.name}
|
||||
</React.Fragment>
|
||||
)}
|
||||
className={classes.root}
|
||||
freeSolo
|
||||
renderInput={(params) => (
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import * as React from 'react'
|
||||
import { TestContext } from 'ra-test'
|
||||
import { DataProviderContext } from 'react-admin'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { SelectPlaylistInput } from './SelectPlaylistInput'
|
||||
|
||||
describe('SelectPlaylistInput', () => {
|
||||
afterEach(cleanup)
|
||||
const onChangeHandler = jest.fn()
|
||||
|
||||
it('should call the handler with the selections', async () => {
|
||||
const mockData = [
|
||||
{ id: 'sample-id1', name: 'sample playlist 1', owner: 'admin' },
|
||||
{ id: 'sample-id2', name: 'sample playlist 2', owner: 'admin' },
|
||||
]
|
||||
const mockIndexedData = {
|
||||
'sample-id1': {
|
||||
id: 'sample-id1',
|
||||
name: 'sample playlist 1',
|
||||
owner: 'admin',
|
||||
},
|
||||
'sample-id2': {
|
||||
id: 'sample-id2',
|
||||
name: 'sample playlist 2',
|
||||
owner: 'admin',
|
||||
},
|
||||
}
|
||||
|
||||
const mockDataProvider = {
|
||||
getList: jest.fn(() =>
|
||||
Promise.resolve({ data: mockData, total: mockData.length })
|
||||
),
|
||||
}
|
||||
|
||||
render(
|
||||
<DataProviderContext.Provider value={mockDataProvider}>
|
||||
<TestContext
|
||||
initialState={{
|
||||
addToPlaylistDialog: { open: true, duplicateSong: false },
|
||||
admin: {
|
||||
ui: { optimistic: false },
|
||||
resources: {
|
||||
playlist: {
|
||||
data: mockIndexedData,
|
||||
list: {
|
||||
cachedRequests: {
|
||||
'{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{}}': {
|
||||
ids: ['sample-id1', 'sample-id2'],
|
||||
total: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SelectPlaylistInput onChange={onChangeHandler} />
|
||||
</TestContext>
|
||||
</DataProviderContext.Provider>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDataProvider.getList).toHaveBeenCalledWith('playlist', {
|
||||
filter: {},
|
||||
pagination: { page: 1, perPage: -1 },
|
||||
sort: { field: 'name', order: 'ASC' },
|
||||
})
|
||||
})
|
||||
|
||||
fireEvent.change(document.activeElement, { target: { value: 'sample' } })
|
||||
fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(document.activeElement, { key: 'Enter' })
|
||||
await waitFor(() => {
|
||||
expect(onChangeHandler).toHaveBeenCalledWith([
|
||||
{ id: 'sample-id1', name: 'sample playlist 1', owner: 'admin' },
|
||||
])
|
||||
})
|
||||
|
||||
fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(document.activeElement, { key: 'Enter' })
|
||||
await waitFor(() => {
|
||||
expect(onChangeHandler).toHaveBeenCalledWith([
|
||||
{ id: 'sample-id1', name: 'sample playlist 1', owner: 'admin' },
|
||||
{ id: 'sample-id2', name: 'sample playlist 2', owner: 'admin' },
|
||||
])
|
||||
})
|
||||
|
||||
fireEvent.change(document.activeElement, {
|
||||
target: { value: 'new playlist' },
|
||||
})
|
||||
fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(document.activeElement, { key: 'Enter' })
|
||||
await waitFor(() => {
|
||||
expect(onChangeHandler).toHaveBeenCalledWith([
|
||||
{ id: 'sample-id1', name: 'sample playlist 1', owner: 'admin' },
|
||||
{ id: 'sample-id2', name: 'sample playlist 2', owner: 'admin' },
|
||||
{ name: 'new playlist' },
|
||||
])
|
||||
})
|
||||
|
||||
fireEvent.change(document.activeElement, {
|
||||
target: { value: 'another new playlist' },
|
||||
})
|
||||
fireEvent.keyDown(document.activeElement, { key: 'Enter' })
|
||||
await waitFor(() => {
|
||||
expect(onChangeHandler).toHaveBeenCalledWith([
|
||||
{ id: 'sample-id1', name: 'sample playlist 1', owner: 'admin' },
|
||||
{ id: 'sample-id2', name: 'sample playlist 2', owner: 'admin' },
|
||||
{ name: 'new playlist' },
|
||||
{ name: 'another new playlist' },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,3 +3,18 @@
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom/extend-expect'
|
||||
|
||||
class LocalStorageMock {
|
||||
constructor() {
|
||||
this.store = {}
|
||||
}
|
||||
getItem(key) {
|
||||
return this.store[key] || null
|
||||
}
|
||||
setItem(key, value) {
|
||||
this.store[key] = String(value)
|
||||
}
|
||||
}
|
||||
|
||||
global.localStorage = new LocalStorageMock()
|
||||
localStorage.setItem('username', 'admin')
|
||||
|
||||
Reference in New Issue
Block a user