From e55491c3a47b5597f927f77589bdf5e88f585a9a Mon Sep 17 00:00:00 2001 From: Howard Wilson Date: Thu, 19 Nov 2020 17:23:05 +0100 Subject: [PATCH] Refactor PlaylistExporter in preparation for adding more data (Includes migration of PlaylistExporter and helpers to TypeScript) --- package.json | 1 + src/components/PlaylistExporter.jsx | 74 ----------------- src/components/PlaylistExporter.tsx | 81 +++++++++++++++++++ src/components/PlaylistRow.jsx | 2 +- src/components/PlaylistTable.test.jsx | 12 ++- src/components/PlaylistsExporter.jsx | 18 +++-- src/components/tracks_data/TracksBaseData.tsx | 80 ++++++++++++++++++ src/components/tracks_data/TracksData.tsx | 12 +++ src/{helpers.jsx => helpers.tsx} | 6 +- src/mocks/handlers.jsx | 16 +++- yarn.lock | 5 ++ 11 files changed, 218 insertions(+), 89 deletions(-) delete mode 100644 src/components/PlaylistExporter.jsx create mode 100644 src/components/PlaylistExporter.tsx create mode 100644 src/components/tracks_data/TracksBaseData.tsx create mode 100644 src/components/tracks_data/TracksData.tsx rename src/{helpers.jsx => helpers.tsx} (92%) diff --git a/package.json b/package.json index 85d6130..19bcabd 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", + "@types/file-saver": "^2.0.1", "@types/jest": "^26.0.15", "@types/node": "^12.0.0", "@types/react": "^16.9.53", diff --git a/src/components/PlaylistExporter.jsx b/src/components/PlaylistExporter.jsx deleted file mode 100644 index b68d584..0000000 --- a/src/components/PlaylistExporter.jsx +++ /dev/null @@ -1,74 +0,0 @@ -import { saveAs } from "file-saver" - -import { apiCall } from "helpers" - -// Handles exporting a single playlist as a CSV file -var PlaylistExporter = { - export: function(accessToken, playlist) { - return this.csvData(accessToken, playlist).then((data) => { - var blob = new Blob([ data ], { type: "text/csv;charset=utf-8" }); - saveAs(blob, this.fileName(playlist), true); - }) - }, - - csvData: async function(accessToken, playlist) { - var requests = []; - var limit = playlist.tracks.limit || 100; - - // Add tracks - for (var offset = 0; offset < playlist.tracks.total; offset = offset + limit) { - requests.push(`${playlist.tracks.href.split('?')[0]}?offset=${offset}&limit=${limit}`) - } - - let promises = requests.map((request) => { - return apiCall(request, accessToken) - }) - - let tracks = (await Promise.all(promises)).flatMap(response => { - return response.data.items.map(item => { - return item.track && [ - item.track.uri, - item.track.name, - item.track.artists.map(function(artist) { return artist.uri }).join(', '), - item.track.artists.map(function(artist) { return String(artist.name).replace(/,/g, "\\,") }).join(', '), - item.track.album.uri, - item.track.album.name, - item.track.disc_number, - item.track.track_number, - item.track.duration_ms, - item.added_by == null ? '' : item.added_by.uri, - item.added_at - ]; - }).filter(e => e) - }) - - tracks.unshift([ - "Track URI", - "Track Name", - "Artist URI", - "Artist Name", - "Album URI", - "Album Name", - "Disc Number", - "Track Number", - "Track Duration (ms)", - "Added By", - "Added At" - ]); - - let csvContent = ''; - - tracks.forEach(function(row, index){ - let dataString = row.map(function (cell) { return '"' + String(cell).replace(/"/g, '""') + '"'; }).join(","); - csvContent += dataString + "\n"; - }); - - return csvContent; - }, - - fileName: function(playlist) { - return playlist.name.replace(/[\x00-\x1F\x7F/\\<>:;"|=,.?*[\] ]+/g, "_").toLowerCase() + ".csv"; // eslint-disable-line no-control-regex - } -} - -export default PlaylistExporter diff --git a/src/components/PlaylistExporter.tsx b/src/components/PlaylistExporter.tsx new file mode 100644 index 0000000..7e699d1 --- /dev/null +++ b/src/components/PlaylistExporter.tsx @@ -0,0 +1,81 @@ +import { saveAs } from "file-saver" + +import TracksData from "components/tracks_data/TracksData" +import TracksBaseData from "components/tracks_data/TracksBaseData" + +class TracksCsvFile { + playlist: any + columnNames: string[] + lineData: Map + + constructor(playlist: any) { + this.playlist = playlist + this.columnNames = [] + this.lineData = new Map() + } + + async addData(tracksData: TracksData) { + this.columnNames.push(...tracksData.dataLabels()) + + const data: Map = await tracksData.data() + + data.forEach((value: string[], key: string) => { + if (this.lineData.has(key)) { + this.lineData.get(key)!.push(...value) + } else { + this.lineData.set(key, value) + } + }) + } + + content(): string { + let csvContent = '' + + csvContent += this.columnNames.map(this.sanitize).join() + "\n" + + this.lineData.forEach((lineData, trackId) => { + csvContent += lineData.map(this.sanitize).join(",") + "\n" + }) + + return csvContent + } + + sanitize(string: string): string { + return '"' + String(string).replace(/"/g, '""') + '"' + } +} + +// Handles exporting a single playlist as a CSV file +class PlaylistExporter { + accessToken: string + playlist: any + + constructor(accessToken: string, playlist: any) { + this.accessToken = accessToken + this.playlist = playlist + } + + async export() { + return this.csvData().then((data) => { + var blob = new Blob([ data ], { type: "text/csv;charset=utf-8" }) + saveAs(blob, this.fileName(), true) + }) + } + + async csvData() { + const tracksCsvFile = new TracksCsvFile(this.playlist) + const tracksBaseData = new TracksBaseData(this.accessToken, this.playlist) + + await tracksCsvFile.addData(tracksBaseData) + + tracksBaseData.tracks() + + return tracksCsvFile.content() + } + + fileName(): string { + return this.playlist.name.replace(/[\x00-\x1F\x7F/\\<>:;"|=,.?*[\] ]+/g, "_").toLowerCase() + ".csv" // eslint-disable-line no-control-regex + } +} + +export default PlaylistExporter diff --git a/src/components/PlaylistRow.jsx b/src/components/PlaylistRow.jsx index 81066b3..2163b7d 100644 --- a/src/components/PlaylistRow.jsx +++ b/src/components/PlaylistRow.jsx @@ -7,7 +7,7 @@ import PlaylistExporter from "./PlaylistExporter" class PlaylistRow extends React.Component { exportPlaylist = () => { - PlaylistExporter.export(this.props.accessToken, this.props.playlist).catch(apiCallErrorHandler) + (new PlaylistExporter(this.props.accessToken, this.props.playlist)).export().catch(apiCallErrorHandler) } renderTickCross(condition) { diff --git a/src/components/PlaylistTable.test.jsx b/src/components/PlaylistTable.test.jsx index 231fb04..10160c0 100644 --- a/src/components/PlaylistTable.test.jsx +++ b/src/components/PlaylistTable.test.jsx @@ -8,7 +8,7 @@ import JSZip from "jszip" import PlaylistTable from "./PlaylistTable" import "../icons" -import { handlers, nullTrackHandlers } from "../mocks/handlers" +import { handlerCalled, handlers, nullTrackHandlers } from "../mocks/handlers" const server = setupServer(...handlers) @@ -75,6 +75,14 @@ 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' ], + [ 'https://api.spotify.com/v1/users/watsonbox/tracks' ], + [ 'https://api.spotify.com/v1/me/tracks?offset=0&limit=20' ] + ]) + expect(saveAsMock).toHaveBeenCalledTimes(1) expect(saveAsMock).toHaveBeenCalledWith( { @@ -124,7 +132,7 @@ describe("single playlist exporting", () => { }) }) -test("exporting of all playlist", async () => { +test("exporting of all playlists", async () => { const saveAsMock = jest.spyOn(FileSaver, "saveAs") saveAsMock.mockImplementation(jest.fn()) diff --git a/src/components/PlaylistsExporter.jsx b/src/components/PlaylistsExporter.jsx index 6fa1bb8..c9c8540 100644 --- a/src/components/PlaylistsExporter.jsx +++ b/src/components/PlaylistsExporter.jsx @@ -44,15 +44,17 @@ class PlaylistsExporter extends React.Component { }, }) - let trackPromises = playlists.map((playlist, index) => { - return PlaylistExporter.csvData(accessToken, playlist).then((csvData) => { - playlistFileNames.push(PlaylistExporter.fileName(playlist)) - playlistCsvExports.push(csvData) - this.props.onExportedPlaylistsCountChanged(index + 1) - }) - }) + let index = 0 + + for (const playlist of playlists) { + let exporter = new PlaylistExporter(accessToken, playlist) + let csvData = await exporter.csvData() - await Promise.all(trackPromises) + playlistFileNames.push(exporter.fileName(playlist)) + playlistCsvExports.push(csvData) + + this.props.onExportedPlaylistsCountChanged(index += 1) + } var zip = new JSZip() diff --git a/src/components/tracks_data/TracksBaseData.tsx b/src/components/tracks_data/TracksBaseData.tsx new file mode 100644 index 0000000..cc3b29c --- /dev/null +++ b/src/components/tracks_data/TracksBaseData.tsx @@ -0,0 +1,80 @@ +import TracksData from "./TracksData" +import { apiCall } from "helpers" + +class TracksBaseData extends TracksData { + playlist: any + + constructor(accessToken: string, playlist: any) { + super(accessToken) + this.playlist = playlist + } + + dataLabels() { + return [ + "Track URI", + "Track Name", + "Artist URI", + "Artist Name", + "Album URI", + "Album Name", + "Disc Number", + "Track Number", + "Track Duration (ms)", + "Added By", + "Added At" + ] + } + + async tracks() { + await this.getPlaylistItems() + + return this.playlistItems.map(i => i.track) + } + + async data() { + await this.getPlaylistItems() + + return new Map(this.playlistItems.map(item => { + return [ + item.track.id, + [ + item.track.uri, + item.track.name, + item.track.artists.map((a: any) => { return a.uri }).join(', '), + item.track.artists.map((a: any) => { return String(a.name).replace(/,/g, "\\,") }).join(', '), + item.track.album.uri, + item.track.album.name, + item.track.disc_number, + item.track.track_number, + item.track.duration_ms, + item.added_by == null ? '' : item.added_by.uri, + item.added_at + ] + ] + })) + } + + // Memoization supporting multiple calls + private playlistItems: any[] = [] + private async getPlaylistItems() { + if (this.playlistItems.length > 0) { + return this.playlistItems + } + + var requests = [] + var limit = this.playlist.tracks.limit || 100 + + for (var offset = 0; offset < this.playlist.tracks.total; offset = offset + limit) { + requests.push(`${this.playlist.tracks.href.split('?')[0]}?offset=${offset}&limit=${limit}`) + } + + const trackPromises = requests.map(request => { return apiCall(request, this.accessToken) }) + const trackResponses = await Promise.all(trackPromises) + + this.playlistItems = trackResponses.flatMap(response => { + return response.data.items.filter((i: any) => i.track) // Exclude null track attributes + }) + } +} + +export default TracksBaseData diff --git a/src/components/tracks_data/TracksData.tsx b/src/components/tracks_data/TracksData.tsx new file mode 100644 index 0000000..da3b1d7 --- /dev/null +++ b/src/components/tracks_data/TracksData.tsx @@ -0,0 +1,12 @@ +abstract class TracksData { + accessToken: string + + constructor(accessToken: string) { + this.accessToken = accessToken + } + + abstract dataLabels(): string[] + abstract data(): Promise> +} + +export default TracksData diff --git a/src/helpers.jsx b/src/helpers.tsx similarity index 92% rename from src/helpers.jsx rename to src/helpers.tsx index 729ab43..afa9d24 100644 --- a/src/helpers.jsx +++ b/src/helpers.tsx @@ -18,7 +18,7 @@ export function authorize() { } // http://stackoverflow.com/a/901144/4167042 -export function getQueryParam(name) { +export function getQueryParam(name: string) { name = name.replace(/[[]/, "\\[").replace(/[\]]/, "\\]"); var regex = new RegExp("[\\?&]" + name + "=([^&#]*)"), results = regex.exec(window.location.search); @@ -51,11 +51,11 @@ limiter.on("failed", async (error, jobInfo) => { } }) -export const apiCall = limiter.wrap(function(url, accessToken) { +export const apiCall = limiter.wrap(function(url: string, accessToken: string) { return axios.get(url, { headers: { 'Authorization': 'Bearer ' + accessToken } }) }) -export function apiCallErrorHandler(error) { +export function apiCallErrorHandler(error: any) { if (error.isAxiosError) { if (error.response.status === 401) { // Return to home page after auth token expiry diff --git a/src/mocks/handlers.jsx b/src/mocks/handlers.jsx index 589504b..9e0540b 100644 --- a/src/mocks/handlers.jsx +++ b/src/mocks/handlers.jsx @@ -1,7 +1,11 @@ import { rest } from 'msw' +export const handlerCalled = jest.fn() + export const handlers = [ rest.get('https://api.spotify.com/v1/me', (req, res, ctx) => { + handlerCalled(req.url.toString()) + if (req.headers.get("Authorization") !== "Bearer TEST_ACCESS_TOKEN") { return res(ctx.status(401), ctx.json({ message: 'Not authorized' })) } @@ -26,6 +30,8 @@ export const handlers = [ }), rest.get('https://api.spotify.com/v1/users/watsonbox/tracks', (req, res, ctx) => { + handlerCalled(req.url.toString()) + if (req.headers.get("Authorization") !== "Bearer TEST_ACCESS_TOKEN") { return res(ctx.status(401), ctx.json({ message: 'Not authorized' })) } @@ -116,6 +122,8 @@ export const handlers = [ // FIXME: Duplication of data rest.get('https://api.spotify.com/v1/me/tracks', (req, res, ctx) => { + handlerCalled(req.url.toString()) + if (req.headers.get("Authorization") !== "Bearer TEST_ACCESS_TOKEN") { return res(ctx.status(401), ctx.json({ message: 'Not authorized' })) } @@ -205,6 +213,8 @@ export const handlers = [ }), rest.get('https://api.spotify.com/v1/users/watsonbox/playlists', (req, res, ctx) => { + handlerCalled(req.url.toString()) + if (req.headers.get("Authorization") !== "Bearer TEST_ACCESS_TOKEN") { return res(ctx.status(401), ctx.json({ message: 'Not authorized' })) } @@ -255,7 +265,9 @@ export const handlers = [ )) }), - rest.get('https://api.spotify.com/v1/playlists/4XOGDpHMrVoH33uJEwHWU5/tracks?offset=0&limit=10', (req, res, ctx) => { + rest.get('https://api.spotify.com/v1/playlists/4XOGDpHMrVoH33uJEwHWU5/tracks', (req, res, ctx) => { + handlerCalled(req.url.toString()) + if (req.headers.get("Authorization") !== "Bearer TEST_ACCESS_TOKEN") { return res(ctx.status(401), ctx.json({ message: 'Not authorized' })) } @@ -450,6 +462,8 @@ export const handlers = [ export const nullTrackHandlers = [ rest.get('https://api.spotify.com/v1/playlists/4XOGDpHMrVoH33uJEwHWU5/tracks?offset=0&limit=10', (req, res, ctx) => { + handlerCalled(req.url.toString()) + if (req.headers.get("Authorization") !== "Bearer TEST_ACCESS_TOKEN") { return res(ctx.status(401), ctx.json({ message: 'Not authorized' })) } diff --git a/yarn.lock b/yarn.lock index 0800b3f..cf1f2b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1702,6 +1702,11 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== +"@types/file-saver@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-2.0.1.tgz#e18eb8b069e442f7b956d313f4fadd3ef887354e" + integrity sha512-g1QUuhYVVAamfCifK7oB7G3aIl4BbOyzDOqVyUfEr4tfBKrXfeH+M+Tg7HKCXSrbzxYdhyCP7z9WbKo0R2hBCw== + "@types/glob@^7.1.1": version "7.1.3" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.3.tgz#e6ba80f36b7daad2c685acd9266382e68985c183"