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:
Yash Jipkate
2021-04-24 04:07:08 +05:30
committed by GitHub
parent d829a63686
commit df57cd6bb5
7 changed files with 834 additions and 56 deletions
+39 -41
View File
@@ -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>
+222
View File
@@ -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)
})
})
})
+35 -13
View File
@@ -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) => (
+115
View File
@@ -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' },
])
})
})
})
+15
View File
@@ -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')