Skip to content

Add more export data #80

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Nov 22, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,33 @@ Track data is exported in [CSV](http://en.wikipedia.org/wiki/Comma-separated_val
- Artist Name
- Album URI
- Album Name
- Album Release Date
- Disc Number
- Track Number
- Track Duration (ms)
- Explicit?
- Popularity
- Added By
- Added At

Additionally, by clicking on the cog and selecting "Include artists data", the following fields will be added:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cog is a really neat UI feature.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, glad you like it. Yep I think it works well and doesn't get in the way.


- Artist Genres

And by selecting "Include audio features data":

- Danceability
- Energy
- Key
- Loudness
- Mode
- Speechiness
- Acousticness
- Instrumentalness
- Liveness
- Valence
- Tempo
- Time Signature

## Development

Expand Down
3 changes: 1 addition & 2 deletions src/App.scss
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ h1 a:hover { color: black; text-decoration: none; }
#playlistsHeader {
display: flex;
flex-direction: row-reverse;
gap: 20px;

.progress {
flex-grow: 1;
Expand All @@ -57,7 +56,7 @@ h1 a:hover { color: black; text-decoration: none; }
text-align: left;

// Transitioning when resetting looks weird
&[aria-valuenow="1"] {
&[aria-valuenow="0"] {
transition: none;
}
}
Expand Down
38 changes: 38 additions & 0 deletions src/components/ConfigDropdown.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
.dropdown-toggle::after {
display: none;
}

.dropdown-menu {
box-shadow: 1px 1px 4px 0px rgba(0,0,0,0.2);
}

.dropdown-item {
&:active, &:hover {
color: black;
background: none;
}

label {
display: block;
cursor: pointer;
}
}

.dropdown {
button {
padding: 0;
margin: 0 20px;
height: 31px;
color: #dee2e6;

&:hover {
color: silver;
}
}

&.show {
button {
color: #5cb85c;
}
}
}
54 changes: 54 additions & 0 deletions src/components/ConfigDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import './ConfigDropdown.scss'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is tsx? A quick Google is only returning cars.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haha. tsx is ts with JSX support (React). And ts is TypeScript.

The idea is that introducing typing is the front-line of defence against introducing bugs.


import React from "react"
import { Dropdown, Form } from "react-bootstrap"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"

type ConfigDropdownProps = {
onConfigChanged: (config: any) => void
}

class ConfigDropdown extends React.Component<ConfigDropdownProps> {
private includeArtistsDataCheck = React.createRef<HTMLInputElement>()
private includeAudioFeaturesDataCheck = React.createRef<HTMLInputElement>()

handleCheckClick = (event: React.MouseEvent) => {
event.stopPropagation()

if ((event.target as HTMLElement).nodeName === "INPUT") {
this.props.onConfigChanged({
includeArtistsData: this.includeArtistsDataCheck.current?.checked || false,
includeAudioFeaturesData: this.includeAudioFeaturesDataCheck.current?.checked || false
})
}
}

render() {
return (
<Dropdown>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So much less verbose than React javascript calls.

<Dropdown.Toggle variant="link">
<FontAwesomeIcon icon={['fas', 'cog']} size="lg" />
</Dropdown.Toggle>
<Dropdown.Menu align="right">
<Dropdown.Item onClickCapture={this.handleCheckClick} as="div">
<Form.Check
id="config-include-artists-data"
type="switch"
label="Include artists data"
ref={this.includeArtistsDataCheck}
/>
</Dropdown.Item>
<Dropdown.Item onClickCapture={this.handleCheckClick} as="div">
<Form.Check
id="config-include-audio-features-data"
type="switch"
label="Include audio features data"
ref={this.includeAudioFeaturesDataCheck}/>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
)
}
}

export default ConfigDropdown
17 changes: 16 additions & 1 deletion src/components/PlaylistExporter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { saveAs } from "file-saver"

import TracksData from "components/tracks_data/TracksData"
import TracksBaseData from "components/tracks_data/TracksBaseData"
import TracksArtistsData from "components/tracks_data/TracksArtistsData"
import TracksAudioFeaturesData from "components/tracks_data/TracksAudioFeaturesData"

class TracksCsvFile {
playlist: any
Expand Down Expand Up @@ -49,10 +51,12 @@ class TracksCsvFile {
class PlaylistExporter {
accessToken: string
playlist: any
config: any

constructor(accessToken: string, playlist: any) {
constructor(accessToken: string, playlist: any, config: any) {
this.accessToken = accessToken
this.playlist = playlist
this.config = config
}

async export() {
Expand All @@ -67,6 +71,17 @@ class PlaylistExporter {
const tracksBaseData = new TracksBaseData(this.accessToken, this.playlist)

await tracksCsvFile.addData(tracksBaseData)
const tracks = await tracksBaseData.tracks()

if (this.config.includeArtistsData) {
const tracksArtistsData = new TracksArtistsData(this.accessToken, tracks)
await tracksCsvFile.addData(tracksArtistsData)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

love that async/await

}

if (this.config.includeAudioFeaturesData) {
const tracksAudioFeaturesData = new TracksAudioFeaturesData(this.accessToken, tracks)
await tracksCsvFile.addData(tracksAudioFeaturesData)
}

tracksBaseData.tracks()

Expand Down
6 changes: 5 additions & 1 deletion src/components/PlaylistRow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import PlaylistExporter from "./PlaylistExporter"

class PlaylistRow extends React.Component {
exportPlaylist = () => {
(new PlaylistExporter(this.props.accessToken, this.props.playlist)).export().catch(apiCallErrorHandler)
(new PlaylistExporter(
this.props.accessToken,
this.props.playlist,
this.props.config
)).export().catch(apiCallErrorHandler)
}

renderTickCross(condition) {
Expand Down
26 changes: 25 additions & 1 deletion src/components/PlaylistTable.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react"
import { ProgressBar } from "react-bootstrap"

import ConfigDropdown from "./ConfigDropdown"
import PlaylistRow from "./PlaylistRow"
import Paginator from "./Paginator"
import PlaylistsExporter from "./PlaylistsExporter"
Expand All @@ -20,6 +21,18 @@ class PlaylistTable extends React.Component {
show: false,
label: "",
value: 0
},
config: {
includeArtistsData: false,
includeAudioFeaturesData: false
}
}

constructor(props) {
super(props)

if (props.config) {
this.state.config = props.config
}
}

Expand Down Expand Up @@ -114,6 +127,10 @@ class PlaylistTable extends React.Component {
})
}

handleConfigChanged = (config) => {
this.setState({ config: config })
}

componentDidMount() {
this.loadPlaylists(this.props.url);
}
Expand All @@ -126,6 +143,7 @@ class PlaylistTable extends React.Component {
<div id="playlists">
<div id="playlistsHeader">
<Paginator nextURL={this.state.nextURL} prevURL={this.state.prevURL} loadPlaylists={this.loadPlaylists}/>
<ConfigDropdown onConfigChanged={this.handleConfigChanged} />
{this.state.progressBar.show && progressBar}
</div>
<table className="table table-hover table-sm">
Expand All @@ -144,13 +162,19 @@ class PlaylistTable extends React.Component {
onExportedPlaylistsCountChanged={this.handleExportedPlaylistsCountChanged}
playlistCount={this.state.playlistCount}
likedSongs={this.state.likedSongs}
config={this.state.config}
/>
</th>
</tr>
</thead>
<tbody>
{this.state.playlists.map((playlist, i) => {
return <PlaylistRow playlist={playlist} key={playlist.id} accessToken={this.props.accessToken}/>
return <PlaylistRow
playlist={playlist}
key={playlist.id}
accessToken={this.props.accessToken}
config={this.state.config}
/>
})}
</tbody>
</table>
Expand Down
97 changes: 88 additions & 9 deletions src/components/PlaylistTable.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ describe("single playlist exporting", () => {
fireEvent.click(linkElement)

await waitFor(() => {
expect(handlerCalled).toHaveBeenCalledTimes(4)
expect(handlerCalled.mock.calls).toEqual([ // Ensure API call order and no duplicates
[ 'https://api.spotify.com/v1/me' ],
[ 'https://api.spotify.com/v1/users/watsonbox/playlists' ],
Expand All @@ -87,8 +86,88 @@ describe("single playlist exporting", () => {
expect(saveAsMock).toHaveBeenCalledWith(
{
content: [
'"Track URI","Track Name","Artist URI","Artist Name","Album URI","Album Name","Disc Number","Track Number","Track Duration (ms)","Added By","Added At"\n' +
'"spotify:track:1GrLfs4TEvAZ86HVzXHchS","Crying","spotify:artist:4TXdHyuAOl3rAOFmZ6MeKz","Six by Seven","spotify:album:4iwv7b8gDPKztLkKCbWyhi","Best of Six By Seven","1","3","198093","","2020-07-19T09:24:39Z"\n'
'"Track URI","Track Name","Artist URI","Artist Name","Album URI","Album Name","Album Release Date","Disc Number","Track Number","Track Duration (ms)","Explicit","Popularity","Added By","Added At"\n' +
'"spotify:track:1GrLfs4TEvAZ86HVzXHchS","Crying","spotify:artist:4TXdHyuAOl3rAOFmZ6MeKz","Six by Seven","spotify:album:4iwv7b8gDPKztLkKCbWyhi","Best of Six By Seven","2017-02-17","1","3","198093","false","2","","2020-07-19T09:24:39Z"\n'
],
options: { type: 'text/csv;charset=utf-8' }
},
'liked.csv',
true
)
})
})

test("including additional artist data", async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you elaborate for me what this testing framework is about and how to use it?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm actually going to write something (possibly a blog article) on working with the specifics of working with Jest and MSW for the first time. Overally it's been great though with a few caveats.

I'm not sure if that's what you were asking or if you were asking a more general question. Just in case, in a more general sense, the test suite is there to validate the behavior of the app. It's mostly about guarding against regressions so I'm free to refactor without worrying too much about breaking things.

In this particular case, most of my tests take the form of mocking out the API calls as close to the HTTP transport layer as possible (so as to be client-agnostic), and then asserting that the app behaves in the expected way given a particular set of responses.

The tests don't actually call the API, so it wouldn't help in the case of breaking changes to the Spotify API. For that I have to rely on user feedback but I also have a last line of defence: exception monitoring with Bugsnag.

const saveAsMock = jest.spyOn(FileSaver, "saveAs")
saveAsMock.mockImplementation(jest.fn())

render(<PlaylistTable accessToken="TEST_ACCESS_TOKEN" config={{ includeArtistsData: true }} />);

await waitFor(() => {
expect(screen.getByText(/Export All/)).toBeInTheDocument()
})

const linkElement = screen.getAllByText("Export")[0]

expect(linkElement).toBeInTheDocument()

fireEvent.click(linkElement)

await waitFor(() => {
expect(handlerCalled.mock.calls).toEqual([ // Ensure API call order and no duplicates
[ 'https://api.spotify.com/v1/me' ],
[ 'https://api.spotify.com/v1/users/watsonbox/playlists' ],
[ 'https://api.spotify.com/v1/users/watsonbox/tracks' ],
[ 'https://api.spotify.com/v1/me/tracks?offset=0&limit=20' ],
[ 'https://api.spotify.com/v1/artists?ids=4TXdHyuAOl3rAOFmZ6MeKz' ]
])

expect(saveAsMock).toHaveBeenCalledTimes(1)
expect(saveAsMock).toHaveBeenCalledWith(
{
content: [
'"Track URI","Track Name","Artist URI","Artist Name","Album URI","Album Name","Album Release Date","Disc Number","Track Number","Track Duration (ms)","Explicit","Popularity","Added By","Added At","Artist Genres"\n' +
'"spotify:track:1GrLfs4TEvAZ86HVzXHchS","Crying","spotify:artist:4TXdHyuAOl3rAOFmZ6MeKz","Six by Seven","spotify:album:4iwv7b8gDPKztLkKCbWyhi","Best of Six By Seven","2017-02-17","1","3","198093","false","2","","2020-07-19T09:24:39Z","nottingham indie"\n'
],
options: { type: 'text/csv;charset=utf-8' }
},
'liked.csv',
true
)
})
})

test("including additional audio features data", async () => {
const saveAsMock = jest.spyOn(FileSaver, "saveAs")
saveAsMock.mockImplementation(jest.fn())

render(<PlaylistTable accessToken="TEST_ACCESS_TOKEN" config={{ includeAudioFeaturesData: true }} />);

await waitFor(() => {
expect(screen.getByText(/Export All/)).toBeInTheDocument()
})

const linkElement = screen.getAllByText("Export")[0]

expect(linkElement).toBeInTheDocument()

fireEvent.click(linkElement)

await waitFor(() => {
expect(handlerCalled.mock.calls).toEqual([ // Ensure API call order and no duplicates
[ 'https://api.spotify.com/v1/me' ],
[ 'https://api.spotify.com/v1/users/watsonbox/playlists' ],
[ 'https://api.spotify.com/v1/users/watsonbox/tracks' ],
[ 'https://api.spotify.com/v1/me/tracks?offset=0&limit=20' ],
[ 'https://api.spotify.com/v1/audio-features?ids=1GrLfs4TEvAZ86HVzXHchS' ]
])

expect(saveAsMock).toHaveBeenCalledTimes(1)
expect(saveAsMock).toHaveBeenCalledWith(
{
content: [
'"Track URI","Track Name","Artist URI","Artist Name","Album URI","Album Name","Album Release Date","Disc Number","Track Number","Track Duration (ms)","Explicit","Popularity","Added By","Added At","Danceability","Energy","Key","Loudness","Mode","Speechiness","Acousticness","Instrumentalness","Liveness","Valence","Tempo","Time Signature"\n' +
'"spotify:track:1GrLfs4TEvAZ86HVzXHchS","Crying","spotify:artist:4TXdHyuAOl3rAOFmZ6MeKz","Six by Seven","spotify:album:4iwv7b8gDPKztLkKCbWyhi","Best of Six By Seven","2017-02-17","1","3","198093","false","2","","2020-07-19T09:24:39Z","0.416","0.971","0","-5.55","1","0.0575","0.00104","0.0391","0.44","0.19","131.988","4"\n'
],
options: { type: 'text/csv;charset=utf-8' }
},
Expand Down Expand Up @@ -121,7 +200,7 @@ describe("single playlist exporting", () => {
expect(saveAsMock).toHaveBeenCalledWith(
{
content: [
'"Track URI","Track Name","Artist URI","Artist Name","Album URI","Album Name","Disc Number","Track Number","Track Duration (ms)","Added By","Added At"\n'
'"Track URI","Track Name","Artist URI","Artist Name","Album URI","Album Name","Album Release Date","Disc Number","Track Number","Track Duration (ms)","Explicit","Popularity","Added By","Added At"\n'
],
options: { type: 'text/csv;charset=utf-8' }
},
Expand Down Expand Up @@ -156,14 +235,14 @@ test("exporting of all playlists", async () => {
expect(jsZipFileMock).toHaveBeenCalledTimes(2)
expect(jsZipFileMock).toHaveBeenCalledWith(
"liked.csv",
'"Track URI","Track Name","Artist URI","Artist Name","Album URI","Album Name","Disc Number","Track Number","Track Duration (ms)","Added By","Added At"\n' +
'"spotify:track:1GrLfs4TEvAZ86HVzXHchS","Crying","spotify:artist:4TXdHyuAOl3rAOFmZ6MeKz","Six by Seven","spotify:album:4iwv7b8gDPKztLkKCbWyhi","Best of Six By Seven","1","3","198093","","2020-07-19T09:24:39Z"\n'
'"Track URI","Track Name","Artist URI","Artist Name","Album URI","Album Name","Album Release Date","Disc Number","Track Number","Track Duration (ms)","Explicit","Popularity","Added By","Added At"\n' +
'"spotify:track:1GrLfs4TEvAZ86HVzXHchS","Crying","spotify:artist:4TXdHyuAOl3rAOFmZ6MeKz","Six by Seven","spotify:album:4iwv7b8gDPKztLkKCbWyhi","Best of Six By Seven","2017-02-17","1","3","198093","false","2","","2020-07-19T09:24:39Z"\n'
)
expect(jsZipFileMock).toHaveBeenCalledWith(
"ghostpoet_–_peanut_butter_blues_and_melancholy_jam.csv",
'"Track URI","Track Name","Artist URI","Artist Name","Album URI","Album Name","Disc Number","Track Number","Track Duration (ms)","Added By","Added At"\n' +
'"spotify:track:7ATyvp3TmYBmGW7YuC8DJ3","One Twos / Run Run Run","spotify:artist:69lEbRQRe29JdyLrewNAvD","Ghostpoet","spotify:album:6jiLkuSnhzDvzsHJlweoGh","Peanut Butter Blues and Melancholy Jam","1","1","241346","spotify:user:watsonbox","2020-11-03T15:19:04Z"\n' +
'"spotify:track:0FNanBLvmFEDyD75Whjj52","Us Against Whatever Ever","spotify:artist:69lEbRQRe29JdyLrewNAvD","Ghostpoet","spotify:album:6jiLkuSnhzDvzsHJlweoGh","Peanut Butter Blues and Melancholy Jam","1","2","269346","spotify:user:watsonbox","2020-11-03T15:19:04Z"\n'
'"Track URI","Track Name","Artist URI","Artist Name","Album URI","Album Name","Album Release Date","Disc Number","Track Number","Track Duration (ms)","Explicit","Popularity","Added By","Added At"\n' +
'"spotify:track:7ATyvp3TmYBmGW7YuC8DJ3","One Twos / Run Run Run","spotify:artist:69lEbRQRe29JdyLrewNAvD","Ghostpoet","spotify:album:6jiLkuSnhzDvzsHJlweoGh","Peanut Butter Blues and Melancholy Jam","2011","1","1","241346","false","22","spotify:user:watsonbox","2020-11-03T15:19:04Z"\n' +
'"spotify:track:0FNanBLvmFEDyD75Whjj52","Us Against Whatever Ever","spotify:artist:69lEbRQRe29JdyLrewNAvD","Ghostpoet","spotify:album:6jiLkuSnhzDvzsHJlweoGh","Peanut Butter Blues and Melancholy Jam","2011","1","2","269346","false","36","spotify:user:watsonbox","2020-11-03T15:19:04Z"\n'
)
})

Expand Down
Loading