diff --git a/.eslintrc.js b/.eslintrc.js index 6194d446ff..3a06c42342 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -26,15 +26,32 @@ module.exports = { rules: { "prettier/prettier": "error", "react/jsx-filename-extension": "off", + "import/extensions": "off", + camelcase: "warn", + "no-unused-vars": "off", + "lines-between-class-members": [ + "error", + "always", + { exceptAfterSingleLine: true }, + ], + }, + settings: { + "import/resolver": { + node: { + extensions: [".js", ".jsx", ".ts", ".tsx"], + }, + }, }, overrides: [ { - files: ["**/*.test.js", "**/*.test.ts"], + files: ["**/*.test.js", "**/*.test.ts", "**/*.test.tsx"], env: { jest: true, }, plugins: ["jest"], rules: { + "import/no-extraneous-dependencies": "off", + "react/jsx-props-no-spreading": "off", "jest/no-disabled-tests": "warn", "jest/no-focused-tests": "error", "jest/no-identical-title": "error", diff --git a/docker/Dockerfile.webpack b/docker/Dockerfile.webpack index afbf71bb0c..f9ff1dfa64 100644 --- a/docker/Dockerfile.webpack +++ b/docker/Dockerfile.webpack @@ -3,5 +3,5 @@ FROM node:10.15-alpine RUN mkdir /code WORKDIR /code -COPY package.json package-lock.json webpack.config.js babel.config.js enzyme.config.js jest.config.js tsconfig.json .eslintrc.js .gitignore /code/ +COPY package.json package-lock.json webpack.config.js babel.config.js enzyme.config.ts jest.config.js tsconfig.json .eslintrc.js .gitignore /code/ RUN npm install diff --git a/enzyme.config.js b/enzyme.config.js deleted file mode 100644 index ebb26cf70a..0000000000 --- a/enzyme.config.js +++ /dev/null @@ -1,5 +0,0 @@ -/* Used in jest.config.js */ -import { configure } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; - -configure({ adapter: new Adapter() }); \ No newline at end of file diff --git a/enzyme.config.ts b/enzyme.config.ts new file mode 100644 index 0000000000..9e7b0de259 --- /dev/null +++ b/enzyme.config.ts @@ -0,0 +1,5 @@ +/* Used in jest.config.js */ +import * as Enzyme from 'enzyme'; +import * as Adapter from 'enzyme-adapter-react-16'; + +Enzyme.configure({ adapter: new Adapter() }); diff --git a/jest.config.js b/jest.config.js index dfd7ae95fd..6ea1f46baa 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,36 +2,38 @@ // https://jestjs.io/docs/en/configuration.html module.exports = { + preset: 'ts-jest', + // Automatically clear mock calls and instances between every test clearMocks: true, - + // An array of glob patterns indicating a set of files for which coverage information should be collected collectCoverageFrom: ['/static/js/**/*.{ts,tsx,js,jsx}'], - + // The directory where Jest should output its coverage files coverageDirectory: 'coverage', - + // An array of file extensions your modules use moduleFileExtensions: ['ts', 'tsx', 'js', 'json', 'jsx'], - + // The paths to modules that run some code to configure or set up the testing environment before each test - setupFiles: ['/enzyme.config.js'], - + setupFiles: ['/enzyme.config.ts'], + // The test environment that will be used for testing testEnvironment: 'jsdom', - + // The glob patterns Jest uses to detect test files - testMatch: ['**/?(*.)+(spec|test).js?(x)'], - + testMatch: ['**/?(*.)+(spec|test).(ts|js)?(x)'], + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped testPathIgnorePatterns: ['\\\\node_modules\\\\'], - + // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href testURL: 'http://localhost', - + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation transformIgnorePatterns: ['/node_modules/'], - + // Indicates whether each individual test should be reported during the run - verbose: false, + verbose: true, }; diff --git a/listenbrainz/webserver/static/js/src/APIError.ts b/listenbrainz/webserver/static/js/src/APIError.ts new file mode 100644 index 0000000000..58061c32b7 --- /dev/null +++ b/listenbrainz/webserver/static/js/src/APIError.ts @@ -0,0 +1,5 @@ +export default class APIError extends Error { + status?: string; + + response?: Response; +} diff --git a/listenbrainz/webserver/static/js/src/api-service.test.js b/listenbrainz/webserver/static/js/src/APIService.test.ts similarity index 73% rename from listenbrainz/webserver/static/js/src/api-service.test.js rename to listenbrainz/webserver/static/js/src/APIService.test.ts index 4bd8123066..49ff670662 100644 --- a/listenbrainz/webserver/static/js/src/api-service.test.js +++ b/listenbrainz/webserver/static/js/src/APIService.test.ts @@ -1,6 +1,4 @@ -/* eslint-disable */ -// TODO: Make the code ESLint compliant -import APIService from "./api-service"; +import APIService from "./APIService"; const apiService = new APIService("foobar"); @@ -14,21 +12,7 @@ describe("submitListens", () => { }); }); - // Mock function for setTimeout and console.warn - console.warn = jest.fn(); - window.setTimeout = jest.fn(); - }); - - it("throws an error if userToken is not a string", async () => { - await expect( - apiService.submitListens(["foo", "bar"], "import", "foobar") - ).rejects.toThrow(SyntaxError); - }); - - it("throws an error if listenType is invalid", async () => { - await expect( - apiService.submitListens("foobar", "foobar", "foobar") - ).rejects.toThrow(SyntaxError); + jest.useFakeTimers(); }); it("calls fetch with correct parameters", async () => { @@ -48,13 +32,20 @@ describe("submitListens", () => { it("retries if submit fails", async () => { // Overide mock for fetch - window.fetch = jest.fn().mockImplementation(() => { - return Promise.reject(); - }); + window.fetch = jest + .fn() + .mockImplementationOnce(() => { + return Promise.reject(Error); + }) + .mockImplementation(() => { + return Promise.resolve({ + ok: true, + status: 200, + }); + }); await apiService.submitListens("foobar", "import", "foobar"); - expect(console.warn).toHaveBeenCalledWith("Error, retrying in 3 sec"); - expect(window.setTimeout).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(1); }); it("retries if error 429 is recieved fails", async () => { @@ -67,8 +58,7 @@ describe("submitListens", () => { }); await apiService.submitListens("foobar", "import", "foobar"); - expect(console.warn).toHaveBeenCalledWith("Error, retrying in 3 sec"); - expect(window.setTimeout).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(1); }); it("skips if any other response code is recieved", async () => { @@ -81,7 +71,7 @@ describe("submitListens", () => { }); await apiService.submitListens("foobar", "import", "foobar"); - expect(console.warn).toHaveBeenCalledWith("Got 404 error, skipping"); + expect(setTimeout).not.toHaveBeenCalled(); // no setTimeout calls for future retries }); it("returns the response if successful", async () => { @@ -121,12 +111,6 @@ describe("getLatestImport", () => { apiService.checkStatus = jest.fn(); }); - it("throws an error if userName is not a string", async () => { - await expect(apiService.getLatestImport(["foo", "bar"])).rejects.toThrow( - SyntaxError - ); - }); - it("encodes url correctly", async () => { await apiService.getLatestImport("ईशान"); expect(window.fetch).toHaveBeenCalledWith( @@ -161,18 +145,6 @@ describe("setLatestImport", () => { apiService.checkStatus = jest.fn(); }); - it("throws an error if userToken is not a string", async () => { - await expect(apiService.setLatestImport(["foo", "bar"], 0)).rejects.toThrow( - SyntaxError - ); - }); - - it("throws an error if timestamp is not a number", async () => { - await expect(apiService.setLatestImport("foobar", "0")).rejects.toThrow( - SyntaxError - ); - }); - it("calls fetch with correct parameters", async () => { await apiService.setLatestImport("foobar", 0); expect(window.fetch).toHaveBeenCalledWith("foobar/1/latest-import", { diff --git a/listenbrainz/webserver/static/js/src/APIService.ts b/listenbrainz/webserver/static/js/src/APIService.ts new file mode 100644 index 0000000000..113fbc8195 --- /dev/null +++ b/listenbrainz/webserver/static/js/src/APIService.ts @@ -0,0 +1,191 @@ +import APIError from "./APIError"; + +export default class APIService { + APIBaseURI: string; + + MAX_LISTEN_SIZE: number = 10000; // Maximum size of listens that can be sent + + constructor(APIBaseURI: string) { + let finalUri = APIBaseURI; + if (finalUri.endsWith("/")) { + finalUri = finalUri.substring(0, APIBaseURI.length - 1); + } + if (!finalUri.endsWith("/1")) { + finalUri += "/1"; + } + this.APIBaseURI = finalUri; + } + + getRecentListensForUsers = async ( + userNames: Array, + limit?: number + ): Promise> => { + const userNamesForQuery: string = userNames.join(","); + + let query = `${this.APIBaseURI}/users/${userNamesForQuery}/recent-listens`; + + if (limit) { + query += `?limit=${limit}`; + } + + const response = await fetch(query, { + method: "GET", + }); + this.checkStatus(response); + const result = await response.json(); + + return result.payload.listens; + }; + + getListensForUser = async ( + userName: string, + minTs?: number, + maxTs?: number, + count?: number + ): Promise> => { + if (maxTs && minTs) { + throw new SyntaxError( + "Cannot have both minTs and maxTs defined at the same time" + ); + } + + let query: string = `${this.APIBaseURI}/user/${userName}/listens`; + + const queryParams: Array = []; + if (maxTs) { + queryParams.push(`max_ts=${maxTs}`); + } + if (minTs) { + queryParams.push(`min_ts=${minTs}`); + } + if (count) { + queryParams.push(`count=${count}`); + } + if (queryParams.length) { + query += `?${queryParams.join("&")}`; + } + + const response = await fetch(query, { + method: "GET", + }); + this.checkStatus(response); + const result = await response.json(); + + return result.payload.listens; + }; + + refreshSpotifyToken = async (): Promise => { + const response = await fetch("/profile/refresh-spotify-token", { + method: "POST", + }); + this.checkStatus(response); + const result = await response.json(); + return result.user_token; + }; + + /* + Send a POST request to the ListenBrainz server to submit a listen + */ + submitListens = async ( + userToken: string, + listenType: ListenType, + payload: SubmitListensPayload + ): Promise => { + if (JSON.stringify(payload).length <= this.MAX_LISTEN_SIZE) { + // Payload is within submission limit, submit directly + const struct = { + listen_type: listenType, + payload, + }; + + const url = `${this.APIBaseURI}/submit-listens`; + + try { + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Token ${userToken}`, + "Content-Type": "application/json;charset=UTF-8", + }, + body: JSON.stringify(struct), + }); + + // we skip listens if we get an error code that's not a rate limit + if (response.status === 429) { + // Rate limit error, this should never happen, but if it does, try again in 3 seconds. + setTimeout( + () => this.submitListens(userToken, listenType, payload), + 3000 + ); + } + return response; // Return response so that caller can handle appropriately + } catch { + // Retry if there is an network error + setTimeout( + () => this.submitListens(userToken, listenType, payload), + 3000 + ); + } + } + + // Payload is not within submission limit, split and submit + await this.submitListens( + userToken, + listenType, + payload.slice(0, payload.length / 2) + ); + return this.submitListens( + userToken, + listenType, + payload.slice(payload.length / 2, payload.length) + ); + }; + + /* + * Send a GET request to the ListenBrainz server to get the latest import time + * from previous imports for the user. + */ + getLatestImport = async (userName: string): Promise => { + const url = encodeURI( + `${this.APIBaseURI}/latest-import?user_name=${userName}` + ); + const response = await fetch(url, { + method: "GET", + }); + this.checkStatus(response); + const result = await response.json(); + return parseInt(result.latest_import, 10); + }; + + /* + * Send a POST request to the ListenBrainz server after the import is complete to + * update the latest import time on the server. This will make future imports stop + * when they reach this point of time in the listen history. + */ + setLatestImport = async ( + userToken: string, + timestamp: number + ): Promise => { + const url = `${this.APIBaseURI}/latest-import`; + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Token ${userToken}`, + "Content-Type": "application/json;charset=UTF-8", + }, + body: JSON.stringify({ ts: timestamp }), + }); + this.checkStatus(response); + return response.status; // Return true if timestamp is updated + }; + + checkStatus = (response: Response): void => { + if (response.status >= 200 && response.status < 300) { + return; + } + const error = new APIError(`HTTP Error ${response.statusText}`); + error.status = response.statusText; + error.response = response; + throw error; + }; +} diff --git a/listenbrainz/webserver/static/js/src/importer.test.js b/listenbrainz/webserver/static/js/src/Importer.test.ts similarity index 67% rename from listenbrainz/webserver/static/js/src/importer.test.js rename to listenbrainz/webserver/static/js/src/Importer.test.ts index a6aa6e6bee..9a975d0c79 100644 --- a/listenbrainz/webserver/static/js/src/importer.test.js +++ b/listenbrainz/webserver/static/js/src/Importer.test.ts @@ -1,45 +1,35 @@ -/* eslint-disable */ -// TODO: Make the code ESLint compliant -import Importer from "./importer"; -import APIService from "./api-service"; +import Importer from "./Importer"; // Mock data to test functions -import page from "./__mocks__/page.json"; -import getInfo from "./__mocks__/getInfo.json"; -import getInfoNoPlayCount from "./__mocks__/getInfoNoPlayCount.json"; +import * as page from "./__mocks__/page.json"; +import * as getInfo from "./__mocks__/getInfo.json"; +import * as getInfoNoPlayCount from "./__mocks__/getInfoNoPlayCount.json"; // Output for the mock data -import encodeScrobble_output from "./__mocks__/encodeScrobble_output.json"; +import * as encodeScrobbleOutput from "./__mocks__/encodeScrobbleOutput.json"; -jest.mock("./api-service"); jest.useFakeTimers(); - const props = { user: { + id: "id", name: "dummyUser", auth_token: "foobar", }, - lastfm_api_url: "http://ws.audioscrobbler.com/2.0/", - lastfm_api_key: "foobar", + profileUrl: "http://profile", + apiUrl: "apiUrl", + lastfmApiUrl: "http://ws.audioscrobbler.com/2.0/", + lastfmApiKey: "foobar", }; const lastfmUsername = "dummyUser"; const importer = new Importer(lastfmUsername, props); describe("encodeScrobbles", () => { - beforeEach(() => { - // Clear previous mocks - APIService.mockClear(); - }); - it("encodes the given scrobbles correctly", () => { - expect(importer.encodeScrobbles(page)).toEqual(encodeScrobble_output); + expect(Importer.encodeScrobbles(page)).toEqual(encodeScrobbleOutput); }); }); describe("getNumberOfPages", () => { beforeEach(() => { - // Clear previous mocks - APIService.mockClear(); - // Mock function for fetch window.fetch = jest.fn().mockImplementation(() => { return Promise.resolve({ @@ -53,7 +43,7 @@ describe("getNumberOfPages", () => { importer.getNumberOfPages(); expect(window.fetch).toHaveBeenCalledWith( - `${props.lastfm_api_url}?method=user.getrecenttracks&user=${lastfmUsername}&api_key=${props.lastfm_api_key}&from=1&format=json` + `${props.lastfmApiUrl}?method=user.getrecenttracks&user=${lastfmUsername}&api_key=${props.lastfmApiKey}&from=1&format=json` ); }); @@ -77,9 +67,6 @@ describe("getNumberOfPages", () => { describe("getTotalNumberOfScrobbles", () => { beforeEach(() => { - // Clear previous mocks - APIService.mockClear(); - // Mock function for fetch window.fetch = jest.fn().mockImplementation(() => { return Promise.resolve({ @@ -93,7 +80,7 @@ describe("getTotalNumberOfScrobbles", () => { importer.getTotalNumberOfScrobbles(); expect(window.fetch).toHaveBeenCalledWith( - `${props.lastfm_api_url}?method=user.getinfo&user=${lastfmUsername}&api_key=${props.lastfm_api_key}&format=json` + `${props.lastfmApiUrl}?method=user.getinfo&user=${lastfmUsername}&api_key=${props.lastfmApiKey}&format=json` ); }); @@ -128,9 +115,6 @@ describe("getTotalNumberOfScrobbles", () => { describe("getPage", () => { beforeEach(() => { - // Clear previous mocks - APIService.mockClear(); - // Mock function for fetch window.fetch = jest.fn().mockImplementation(() => { return Promise.resolve({ @@ -144,16 +128,16 @@ describe("getPage", () => { importer.getPage(1); expect(window.fetch).toHaveBeenCalledWith( - `${props.lastfm_api_url}?method=user.getrecenttracks&user=${lastfmUsername}&api_key=${props.lastfm_api_key}&from=1&page=1&format=json` + `${props.lastfmApiUrl}?method=user.getrecenttracks&user=${lastfmUsername}&api_key=${props.lastfmApiKey}&from=1&page=1&format=json` ); }); it("should call encodeScrobbles", async () => { // Mock function for encodeScrobbles - importer.encodeScrobbles = jest.fn((data) => ["foo", "bar"]); + Importer.encodeScrobbles = jest.fn(() => ["foo", "bar"]); const data = await importer.getPage(1); - expect(importer.encodeScrobbles).toHaveBeenCalledTimes(1); + expect(Importer.encodeScrobbles).toHaveBeenCalledTimes(1); expect(data).toEqual(["foo", "bar"]); }); @@ -165,15 +149,10 @@ describe("getPage", () => { status: 503, }); }); - // Mock function for console.warn - console.warn = jest.fn(); await importer.getPage(1); // There is no direct way to check if retry has been called expect(setTimeout).toHaveBeenCalledTimes(1); - expect(console.warn).toHaveBeenCalledWith( - "Got 503 fetching last.fm page=1, retrying in 3s" - ); jest.runAllTimers(); }); @@ -187,11 +166,8 @@ describe("getPage", () => { }); }); - // Mock function for console.warn - console.warn = jest.fn(); - await importer.getPage(1); - expect(console.warn).toHaveBeenCalledWith("Got 404, skipping"); + expect(setTimeout).not.toHaveBeenCalled(); }); it("should retry if there is any other error", async () => { @@ -202,41 +178,26 @@ describe("getPage", () => { json: () => Promise.reject(), }); }); - // Mock function for console.warn - console.warn = jest.fn(); await importer.getPage(1); // There is no direct way to check if retry has been called expect(setTimeout).toHaveBeenCalledTimes(1); - expect(console.warn).toHaveBeenCalledWith( - "Error fetching last.fm page=1, retrying in 3s" - ); - jest.runAllTimers(); }); }); describe("submitPage", () => { beforeEach(() => { - // Clear previous mocks - APIService.mockClear(); - - // Mock for getRateLimitDelay and updateRateLimitParameters importer.getRateLimitDelay = jest.fn().mockImplementation(() => 0); importer.updateRateLimitParameters = jest.fn(); - - // Mock for console.warn - console.warn = jest.fn(); }); it("calls submitListens once", async () => { - const spy = jest - .spyOn(importer.APIService, "submitListens") - .mockImplementation(async () => { - return { status: 200 }; - }); + importer.APIService.submitListens = jest.fn().mockImplementation(() => { + return Promise.resolve({ status: 200 }); + }); + importer.submitPage(["listen"]); - importer.submitPage(); jest.runAllTimers(); // Flush all promises @@ -244,21 +205,14 @@ describe("submitPage", () => { await new Promise((resolve) => setImmediate(resolve)); expect(importer.APIService.submitListens).toHaveBeenCalledTimes(1); - expect(importer.APIService.submitListens).toHaveBeenCalledWith( - "foobar", - "import", - undefined - ); }); it("calls updateRateLimitParameters once", async () => { - const spy = jest - .spyOn(importer.APIService, "submitListens") - .mockImplementation(async () => { - return { status: 200 }; - }); + importer.APIService.submitListens = jest.fn().mockImplementation(() => { + return Promise.resolve({ status: 200 }); + }); + importer.submitPage(["listen"]); - importer.submitPage(); jest.runAllTimers(); // Flush all promises @@ -272,7 +226,7 @@ describe("submitPage", () => { }); it("calls getRateLimitDelay once", async () => { - importer.submitPage(); + importer.submitPage(["listen"]); expect(importer.getRateLimitDelay).toHaveBeenCalledTimes(1); }); }); diff --git a/listenbrainz/webserver/static/js/src/importer.jsx b/listenbrainz/webserver/static/js/src/Importer.tsx similarity index 63% rename from listenbrainz/webserver/static/js/src/importer.jsx rename to listenbrainz/webserver/static/js/src/Importer.tsx index 7463fc1eb3..0c8da2a1c0 100644 --- a/listenbrainz/webserver/static/js/src/importer.jsx +++ b/listenbrainz/webserver/static/js/src/Importer.tsx @@ -1,52 +1,56 @@ -/* eslint-disable */ -// TODO: Make the code ESLint compliant -// TODO: Port to typescript - -import React from "react"; +import * as React from "react"; import { faSpinner, faCheck } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import Scrobble from "./scrobble"; -import APIService from "./api-service"; +import { IconProp } from "@fortawesome/fontawesome-svg-core"; +import Scrobble from "./Scrobble"; +import APIService from "./APIService"; export default class Importer { - constructor(lastfmUsername, props) { - this.APIService = new APIService( - props.api_url || `${window.location.origin}/1` - ); // Used to access LB API + APIService: APIService; - this.lastfmUsername = lastfmUsername; - this.lastfmURL = props.lastfm_api_url; - this.lastfmKey = props.lastfm_api_key; + private lastfmUsername: string; + private lastfmURL: string; + private lastfmKey: string; - this.userName = props.user.name; - this.userToken = props.user.auth_token; + private userName: string; + private userToken: string; - this.page = 1; - this.totalPages = 0; - this.playCount = -1; // the number of scrobbles reported by Last.FM - this.countReceived = 0; // number of scrobbles the Last.FM API sends us, this can be diff from playCount + private page = 1; + private totalPages = 0; - this.latestImportTime = 0; // the latest timestamp that we've imported earlier - this.maxTimestampForImport = 0; // the latest listen found in this import - this.incrementalImport = false; + private playCount = -1; // the number of scrobbles reported by Last.FM + private countReceived = 0; // number of scrobbles the Last.FM API sends us, this can be diff from playCount - this.numCompleted = 0; // number of pages completed till now + private latestImportTime = 0; // the latest timestamp that we've imported earlier + private maxTimestampForImport = 0; // the latest listen found in this import + private incrementalImport = false; - this.props = props; + private numCompleted = 0; // number of pages completed till now - // Variables used to honor LB's rate limit - this.rl_remain = -1; - this.rl_reset = -1; - this.rl_origin = -1; + // Variables used to honor LB's rate limit + private rlRemain = -1; + private rlReset = -1; + private rlOrigin = -1; - // Message to be outputed in modal - this.msg = ""; - this.canClose = true; + public msg?: React.ReactElement; // Message to be displayed in modal + public canClose = true; + + constructor(lastfmUsername: string, private props: ImporterProps) { + this.APIService = new APIService( + props.apiUrl || `${window.location.origin}/1` + ); // Used to access LB API + + this.lastfmUsername = lastfmUsername; + this.lastfmURL = props.lastfmApiUrl; + this.lastfmKey = props.lastfmApiKey; + + this.userName = props.user.name; + this.userToken = props.user.auth_token; } async startImport() { this.canClose = false; // Disable the close button - this.updateMessage("Your import from Last.fm is starting!"); + this.updateMessage(

Your import from Last.fm is starting!

); this.playCount = await this.getTotalNumberOfScrobbles(); this.latestImportTime = await this.APIService.getLatestImport( this.userName @@ -56,19 +60,20 @@ export default class Importer { this.page = this.totalPages; // Start from the last page so that oldest scrobbles are imported first while (this.page > 0) { - const payload = await this.getPage(this.page); + // Fixing no-await-in-loop will require significant changes to the code, ignoring for now + const payload = await this.getPage(this.page); // eslint-disable-line if (payload) { // Submit only if response is valid this.submitPage(payload); } - this.page--; - this.numCompleted++; + this.page -= 1; + this.numCompleted += 1; // Update message const msg = (

- Sending page{" "} + Sending page{" "} {this.numCompleted} of {this.totalPages} to ListenBrainz
{this.incrementalImport && ( @@ -78,7 +83,7 @@ export default class Importer {
)} - Please don't close this page while this is running + Please don't close this page while this is running

); @@ -88,7 +93,7 @@ export default class Importer { // Update latest import time on LB server try { this.maxTimestampForImport = Math.max( - parseInt(this.maxTimestampForImport), + Number(this.maxTimestampForImport), this.latestImportTime ); this.APIService.setLatestImport( @@ -96,7 +101,7 @@ export default class Importer { this.maxTimestampForImport ); } catch { - console.warn("Error, retrying in 3s"); + // console.warn("Error setting latest import timestamp, retrying in 3s"); setTimeout( () => this.APIService.setLatestImport( @@ -106,9 +111,9 @@ export default class Importer { 3000 ); } - const final_msg = ( + const finalMsg = (

- Import finished + Import finished
Successfully submitted {this.countReceived} listens to ListenBrainz @@ -119,8 +124,8 @@ export default class Importer { * and playCount will be different by definition in incremental imports */} {!this.incrementalImport && - this.playCount != -1 && - this.countReceived != this.playCount && ( + this.playCount !== -1 && + this.countReceived !== this.playCount && ( The number submitted listens is different from the{" "} @@ -136,13 +141,13 @@ export default class Importer {

- + Close and go to your ListenBrainz profile

); - this.updateMessage(final_msg); + this.updateMessage(finalMsg); this.canClose = true; } @@ -156,13 +161,13 @@ export default class Importer { const response = await fetch(encodeURI(url)); const data = await response.json(); if ("playcount" in data.user) { - return parseInt(data.user.playcount); + return Number(data.user.playcount); } return -1; } catch { - this.updateMessage("An error occurred, please try again. :("); + this.updateMessage(

An error occurred, please try again. :(

); this.canClose = true; // Enable the close button - error = new Error(); + const error = new Error(); error.message = "Something went wrong"; throw error; } @@ -180,23 +185,23 @@ export default class Importer { const response = await fetch(encodeURI(url)); const data = await response.json(); if ("recenttracks" in data) { - return parseInt(data.recenttracks["@attr"].totalPages); + return Number(data.recenttracks["@attr"].totalPages); } return 0; } catch (error) { - this.updateMessage("An error occurred, please try again. :("); + this.updateMessage(

An error occurred, please try again. :(

); this.canClose = true; // Enable the close button return -1; } } - async getPage(page) { + async getPage(page: number) { /* * Fetch page from Last.fm */ - const retry = (reason) => { - console.warn(`${reason} fetching last.fm page=${page}, retrying in 3s`); + const retry = (reason: string) => { + // console.warn(`${reason} while fetching last.fm page=${page}, retrying in 3s`); setTimeout(() => this.getPage(page), 3000); }; @@ -220,23 +225,25 @@ export default class Importer { } // Encode the page so that it can be submitted - const payload = this.encodeScrobbles(data); + const payload = Importer.encodeScrobbles(data); this.countReceived += payload.length; return payload; } - if (/^5/.test(response.status)) { + if (/^5/.test(response.status.toString())) { retry(`Got ${response.status}`); } else { // ignore 40x - console.warn(`Got ${response.status}, skipping`); + // console.warn(`Got ${response.status} while fetching page last.fm page=${page}, skipping`); } } catch { // Retry if there is a network error retry("Error"); } + return null; } - async submitPage(payload) { + // TODO: Replace with array of Listens + async submitPage(payload: any) { const delay = this.getRateLimitDelay(); // Halt execution for some time await new Promise((resolve) => { @@ -251,18 +258,19 @@ export default class Importer { this.updateRateLimitParameters(response); } - encodeScrobbles(scrobbles) { - scrobbles = scrobbles.recenttracks.track; - const parsedScrobbles = this.map((rawScrobble) => { + // TODO: Replace return type with array of Listens + static encodeScrobbles(scrobbles: LastFmScrobblePage): any { + const rawScrobbles = scrobbles.recenttracks.track; + const parsedScrobbles = Importer.map((rawScrobble: any) => { const scrobble = new Scrobble(rawScrobble); return scrobble.asJSONSerializable(); - }, scrobbles); + }, rawScrobbles); return parsedScrobbles; } - map(applicable, collection) { + static map(applicable: Function, collection: any) { const newCollection = []; - for (let i = 0; i < collection.length; i++) { + for (let i = 0; i < collection.length; i += 1) { const result = applicable(collection[i]); if ("listened_at" in result) { // Add If there is no 'listened_at' attribute then either the listen is invalid or the @@ -273,7 +281,7 @@ export default class Importer { return newCollection; } - updateMessage = (msg) => { + updateMessage = (msg: React.ReactElement) => { this.msg = msg; }; @@ -281,20 +289,20 @@ export default class Importer { /* Get the amount of time we should wait according to LB rate limits before making a request to LB */ let delay = 0; const current = new Date().getTime() / 1000; - if (this.rl_reset < 0 || current > this.rl_origin + this.rl_reset) { + if (this.rlReset < 0 || current > this.rlOrigin + this.rlReset) { delay = 0; - } else if (this.rl_remain > 0) { - delay = Math.max(0, Math.ceil((this.rl_reset * 1000) / this.rl_remain)); + } else if (this.rlRemain > 0) { + delay = Math.max(0, Math.ceil((this.rlReset * 1000) / this.rlRemain)); } else { - delay = Math.max(0, Math.ceil(this.rl_reset * 1000)); + delay = Math.max(0, Math.ceil(this.rlReset * 1000)); } return delay; } - updateRateLimitParameters(response) { + updateRateLimitParameters(response: Response) { /* Update the variables we use to honor LB's rate limits */ - this.rl_remain = parseInt(response.headers.get("X-RateLimit-Remaining")); - this.rl_reset = parseInt(response.headers.get("X-RateLimit-Reset-In")); - this.rl_origin = new Date().getTime() / 1000; + this.rlRemain = Number(response.headers.get("X-RateLimit-Remaining")); + this.rlReset = Number(response.headers.get("X-RateLimit-Reset-In")); + this.rlOrigin = new Date().getTime() / 1000; } } diff --git a/listenbrainz/webserver/static/js/src/lastFmImporter.test.js b/listenbrainz/webserver/static/js/src/LastFMImporter.test.tsx similarity index 54% rename from listenbrainz/webserver/static/js/src/lastFmImporter.test.js rename to listenbrainz/webserver/static/js/src/LastFMImporter.test.tsx index af38b08760..6b71fb1570 100644 --- a/listenbrainz/webserver/static/js/src/lastFmImporter.test.js +++ b/listenbrainz/webserver/static/js/src/LastFMImporter.test.tsx @@ -1,38 +1,39 @@ -/* eslint-disable */ -// TODO: Make the code ESLint compliant -import React from "react"; +import * as React from "react"; import { shallow } from "enzyme"; -import LastFmImporter from "./lastFmImporter"; -import Importer from "./importer"; +import LastFmImporter from "./LastFMImporter"; -jest.mock("./importer"); +const props = { + user: { + id: "id", + name: "dummyUser", + auth_token: "foobar", + }, + profileUrl: "http://profile", + apiUrl: "apiUrl", + lastfmApiUrl: "http://ws.audioscrobbler.com/2.0/", + lastfmApiKey: "foobar", +}; -let wrapper = null; describe("LastFmImporter Page", () => { - beforeEach(() => { - // Clear previous mocks - Importer.mockClear(); - - // Mount each time - wrapper = shallow(); - }); - it("renders without crashing", () => { + const wrapper = shallow(); expect(wrapper).toBeTruthy(); }); it("modal renders when button clicked", () => { + const wrapper = shallow(); // Simulate submiting the form wrapper.find("form").simulate("submit", { preventDefault: () => null, }); // Test if the show property has been set to true - expect(wrapper.exists("Modal")).toBe(true); + expect(wrapper.exists("LastFMImporterModal")).toBe(true); }); it("submit button is disabled when input is empty", () => { + const wrapper = shallow(); // Make sure that the input is empty wrapper.setState({ lastfmUsername: "" }); diff --git a/listenbrainz/webserver/static/js/src/LastFMImporter.tsx b/listenbrainz/webserver/static/js/src/LastFMImporter.tsx new file mode 100644 index 0000000000..4b31cc5097 --- /dev/null +++ b/listenbrainz/webserver/static/js/src/LastFMImporter.tsx @@ -0,0 +1,112 @@ +import * as ReactDOM from "react-dom"; +import * as React from "react"; +import Importer from "./Importer"; + +import LastFMImporterModal from "./LastFMImporterModal"; + +export default class LastFmImporter extends React.Component< + ImporterProps, + ImporterState +> { + importer: any; + + constructor(props: ImporterProps) { + super(props); + + this.state = { + show: false, + canClose: true, + lastfmUsername: "", + msg: "", + }; + } + + handleChange = (event: React.ChangeEvent) => { + this.setState({ lastfmUsername: event.target.value }); + }; + + handleSubmit = (event: React.FormEvent) => { + const { lastfmUsername } = this.state; + this.toggleModal(); + event.preventDefault(); + this.importer = new Importer(lastfmUsername, this.props); + setInterval(this.updateMessage, 100); + setInterval(this.setClose, 100); + this.importer.startImport(); + }; + + toggleModal = () => { + this.setState((prevState) => { + return { show: !prevState.show }; + }); + }; + + setClose = () => { + this.setState({ canClose: this.importer.canClose }); + }; + + updateMessage = () => { + this.setState({ msg: this.importer.msg }); + }; + + render() { + const { show, canClose, lastfmUsername, msg } = this.state; + + return ( +
+
+ + +
+ {show && ( + + +
+
+
{msg}
+
+
+ )} +
+ ); + } +} + +document.addEventListener("DOMContentLoaded", () => { + const domContainer = document.querySelector("#react-container"); + const propsElement = document.getElementById("react-props"); + let reactProps; + try { + reactProps = JSON.parse(propsElement!.innerHTML); + } catch (err) { + // Show error to the user and ask to reload page + } + const { + user, + profile_url, + api_url, + lastfm_api_url, + lastfm_api_key, + } = reactProps; + ReactDOM.render( + , + domContainer + ); +}); diff --git a/listenbrainz/webserver/static/js/src/lastFmImporterModal.test.js b/listenbrainz/webserver/static/js/src/LastFMImporterModal.test.tsx similarity index 61% rename from listenbrainz/webserver/static/js/src/lastFmImporterModal.test.js rename to listenbrainz/webserver/static/js/src/LastFMImporterModal.test.tsx index 810bd19f36..ba7030fe3a 100644 --- a/listenbrainz/webserver/static/js/src/lastFmImporterModal.test.js +++ b/listenbrainz/webserver/static/js/src/LastFMImporterModal.test.tsx @@ -1,22 +1,22 @@ -/* eslint-disable */ -// TODO: Make the code ESLint compliant -import React from "react"; +import * as React from "react"; import { shallow } from "enzyme"; -import LastFmImporterModal from "./lastFmImporterModal"; +import LastFMImporterModal from "./LastFMImporterModal"; -let wrapper = null; -describe("LastFmImporterModal", () => { - beforeEach(() => { - // Mount each time - wrapper = shallow(); - }); +const props = { + disable: false, + children: [], + onClose: (event: React.MouseEvent) => {}, +}; +describe("LastFmImporterModal", () => { it("renders without crashing", () => { + const wrapper = shallow(); expect(wrapper).toBeTruthy(); }); it("close button is disabled/enabled based upon props", () => { + const wrapper = shallow(); // Test if close button is disabled wrapper.setProps({ disable: true }); expect(wrapper.find("button").props().disabled).toBe(true); diff --git a/listenbrainz/webserver/static/js/src/LastFMImporterModal.tsx b/listenbrainz/webserver/static/js/src/LastFMImporterModal.tsx new file mode 100644 index 0000000000..37b3110763 --- /dev/null +++ b/listenbrainz/webserver/static/js/src/LastFMImporterModal.tsx @@ -0,0 +1,49 @@ +import * as React from "react"; +import { faTimes } from "@fortawesome/free-solid-svg-icons"; +import { IconProp } from "@fortawesome/fontawesome-svg-core"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +const LastFMImporterModal = (props: ModalProps) => { + const divStyle = { + position: "fixed", + height: "90%", + maxHeight: "300px", + top: "50%", + zIndex: 2, + width: "90%", + maxWidth: "500px", + left: "50%", + transform: "translate(-50%, -50%)", + backgroundColor: "#fff", + boxShadow: "0 19px 38px rgba(0,0,0,0.30), 0 15px 12px rgba(0,0,0,0.22)", + textAlign: "center", + padding: "50px", + } as React.CSSProperties; + + const buttonStyle = { + position: "absolute", + top: "5px", + right: "10px", + outline: "none", + border: "none", + background: "transparent", + } as React.CSSProperties; + + const { children, onClose, disable } = props; + + return ( +
+ +
{children}
+
+ ); +}; + +export default LastFMImporterModal; diff --git a/listenbrainz/webserver/static/js/src/scrobble.js b/listenbrainz/webserver/static/js/src/Scrobble.ts similarity index 80% rename from listenbrainz/webserver/static/js/src/scrobble.js rename to listenbrainz/webserver/static/js/src/Scrobble.ts index b02edcb26c..1178be8f57 100644 --- a/listenbrainz/webserver/static/js/src/scrobble.js +++ b/listenbrainz/webserver/static/js/src/Scrobble.ts @@ -1,26 +1,11 @@ -/* eslint-disable */ -// TODO: Make the code ESLint compliant -// TODO: Port to typescript - export default class Scrobble { - constructor(rootScrobbleElement) { - this.rootScrobbleElement = rootScrobbleElement; - } + private rootScrobbleElement: any; - lastfmID() { - /* Returns url of type "http://www.last.fm/music/Mot%C3%B6rhead" */ - if ( - "url" in this.rootScrobbleElement && - this.rootScrobbleElement.url !== "" - ) { - let { url } = this.rootScrobbleElement; - url = url.split("/"); - return url.slice(0, parts.length - 2).join("/"); - } - return ""; + constructor(rootScrobbleElement: any) { + this.rootScrobbleElement = rootScrobbleElement; } - artistName() { + artistName(): string { /* Returns artistName if present, else returns an empty string */ if ( "artist" in this.rootScrobbleElement && @@ -31,7 +16,7 @@ export default class Scrobble { return ""; } - artistMBID() { + artistMBID(): string { /* Returns artistName if present, else returns an empty string */ if ( "artist" in this.rootScrobbleElement && @@ -42,7 +27,7 @@ export default class Scrobble { return ""; } - trackName() { + trackName(): string { /* Returns trackName if present, else returns an empty string */ if ("name" in this.rootScrobbleElement) { return this.rootScrobbleElement.name; @@ -50,7 +35,7 @@ export default class Scrobble { return ""; } - releaseName() { + releaseName(): string { /* Returns releaseName if present, else returns an empty string */ if ( "album" in this.rootScrobbleElement && @@ -61,7 +46,7 @@ export default class Scrobble { return ""; } - releaseMBID() { + releaseMBID(): string { /* Returns releaseMBID if present, else returns an empty string */ if ( "album" in this.rootScrobbleElement && @@ -72,7 +57,7 @@ export default class Scrobble { return ""; } - scrobbledAt() { + scrobbledAt(): string { /* Returns scrobbledAt if present, else returns an empty string */ if ( "date" in this.rootScrobbleElement && @@ -90,7 +75,7 @@ export default class Scrobble { return ""; } - trackMBID() { + trackMBID(): string { /* Returns trackMBID if present, else returns an empty string */ if ("mbid" in this.rootScrobbleElement) { return this.rootScrobbleElement.mbid; @@ -98,7 +83,8 @@ export default class Scrobble { return ""; } - asJSONSerializable() { + // TODO: Make this type as Listen + asJSONSerializable(): any { const trackjson = { track_metadata: { track_name: this.trackName(), @@ -115,24 +101,25 @@ export default class Scrobble { }; // Remove keys with blank values - (function filter(obj) { + (function filter(obj: any) { Object.keys(obj).forEach(function (key) { let value = obj[key]; if (value === "" || value === null) { - delete obj[key]; + delete obj[key]; // eslint-disable-line no-param-reassign } else if ( Object.prototype.toString.call(value) === "[object Object]" ) { filter(value); } else if (Array.isArray(value)) { value = value.filter(Boolean); - obj[key] = value; - value.forEach(function (el) { + obj[key] = value; // eslint-disable-line no-param-reassign + value.forEach(function (el: any) { filter(el); }); } }); })(trackjson); + return trackjson; } } diff --git a/listenbrainz/webserver/static/js/src/__mocks__/encodeScrobble_output.json b/listenbrainz/webserver/static/js/src/__mocks__/encodeScrobbleOutput.json similarity index 100% rename from listenbrainz/webserver/static/js/src/__mocks__/encodeScrobble_output.json rename to listenbrainz/webserver/static/js/src/__mocks__/encodeScrobbleOutput.json diff --git a/listenbrainz/webserver/static/js/src/api-service.js b/listenbrainz/webserver/static/js/src/api-service.js deleted file mode 100644 index d617186bed..0000000000 --- a/listenbrainz/webserver/static/js/src/api-service.js +++ /dev/null @@ -1,233 +0,0 @@ -/* eslint-disable */ -// TODO: Make the code ESLint compliant -// TODO: Port to typescript - -import { isFinite, isNil, isString } from "lodash"; - -export default class APIService { - APIBaseURI; - - constructor(APIBaseURI) { - if (isNil(APIBaseURI) || !isString(APIBaseURI)) { - throw new SyntaxError( - `Expected API base URI string, got ${typeof APIBaseURI} instead` - ); - } - if (APIBaseURI.endsWith("/")) { - APIBaseURI = APIBaseURI.substring(0, APIBaseURI.length - 1); - } - if (!APIBaseURI.endsWith("/1")) { - APIBaseURI += "/1"; - } - this.APIBaseURI = APIBaseURI; - this.MAX_LISTEN_SIZE = 10000; // Maximum size of listens that can be sent - } - - async getRecentListensForUsers(userNames, limit) { - let userNamesForQuery = userNames; - if (Array.isArray(userNames)) { - userNamesForQuery = userNames.join(","); - } else if (typeof userNames !== "string") { - throw new SyntaxError( - `Expected username or array of username strings, got ${typeof userNames} instead` - ); - } - - let query = `${this.APIBaseURI}/users/${userNamesForQuery}/recent-listens`; - - if (!isNil(limit) && isFinite(Number(limit))) { - query += `?limit=${limit}`; - } - - const response = await fetch(query, { - accept: "application/json", - method: "GET", - }); - this.checkStatus(response); - const result = await response.json(); - - return result.payload.listens; - } - - async getListensForUser(userName, minTs, maxTs, count) { - if (typeof userName !== "string") { - throw new SyntaxError( - `Expected username string, got ${typeof userName} instead` - ); - } - if (!isNil(maxTs) && !isNil(minTs)) { - throw new SyntaxError( - "Cannot have both minTs and maxTs defined at the same time" - ); - } - - let query = `${this.APIBaseURI}/user/${userName}/listens`; - - const queryParams = []; - if (!isNil(maxTs) && isFinite(Number(maxTs))) { - queryParams.push(`max_ts=${maxTs}`); - } - if (!isNil(minTs) && isFinite(Number(minTs))) { - queryParams.push(`min_ts=${minTs}`); - } - if (!isNil(count) && isFinite(Number(count))) { - queryParams.push(`count=${count}`); - } - if (queryParams.length) { - query += `?${queryParams.join("&")}`; - } - - const response = await fetch(query, { - accept: "application/json", - method: "GET", - }); - this.checkStatus(response); - const result = await response.json(); - - return result.payload.listens; - } - - async refreshSpotifyToken() { - const response = await fetch("/profile/refresh-spotify-token", { - method: "POST", - }); - this.checkStatus(response); - const result = await response.json(); - return result.user_token; - } - - async submitListens(userToken, listenType, payload) { - /* - Send a POST request to the ListenBrainz server to submit a listen - */ - - if (!isString(userToken)) { - throw new SyntaxError( - `Expected usertoken string, got ${typeof userToken} instead` - ); - } - if ( - listenType !== "single" && - listenType !== "playingNow" && - listenType !== "import" - ) { - throw new SyntaxError( - `listenType can be "single", "playingNow" or "import", got ${listenType} instead` - ); - } - - if (JSON.stringify(payload).length <= this.MAX_LISTEN_SIZE) { - // Payload is within submission limit, submit directly - const struct = { - listen_type: listenType, - payload, - }; - - const url = `${this.APIBaseURI}/submit-listens`; - - try { - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Token ${userToken}`, - "Content-Type": "application/json;charset=UTF-8", - }, - body: JSON.stringify(struct), - }); - - if (response.status == 429) { - // This should never happen, but if it does, try again. - console.warn("Error, retrying in 3 sec"); - setTimeout( - () => this.submitListens(userToken, listenType, payload), - 3000 - ); - } else if (!(response.status >= 200 && response.status < 300)) { - console.warn(`Got ${response.status} error, skipping`); - } - return response; // Return response so that caller can handle appropriately - } catch { - // Retry if there is an network error - console.warn("Error, retrying in 3 sec"); - setTimeout( - () => this.submitListens(userToken, listenType, payload), - 3000 - ); - } - } else { - // Payload is not within submission limit, split and submit - await this.submitListens( - userToken, - listenType, - payload.slice(0, payload.length / 2) - ); - return await this.submitListens( - userToken, - listenType, - payload.slice(payload.length / 2, payload.length) - ); - } - } - - async getLatestImport(userName) { - /* - * Send a GET request to the ListenBrainz server to get the latest import time - * from previous imports for the user. - */ - - if (!isString(userName)) { - throw new SyntaxError( - `Expected username string, got ${typeof userName} instead` - ); - } - let url = `${this.APIBaseURI}/latest-import?user_name=${userName}`; - url = encodeURI(url); - const response = await fetch(url, { - method: "GET", - }); - this.checkStatus(response); - const result = await response.json(); - return parseInt(result.latest_import); - } - - async setLatestImport(userToken, timestamp) { - /* - * Send a POST request to the ListenBrainz server after the import is complete to - * update the latest import time on the server. This will make future imports stop - * when they reach this point of time in the listen history. - */ - - if (!isString(userToken)) { - throw new SyntaxError( - `Expected usertoken string, got ${typeof userToken} instead` - ); - } - if (!isFinite(timestamp)) { - throw new SyntaxError( - `Expected timestamp number, got ${typeof timestamp} instead` - ); - } - - const url = `${this.APIBaseURI}/latest-import`; - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Token ${userToken}`, - "Content-Type": "application/json;charset=UTF-8", - }, - body: JSON.stringify({ ts: parseInt(timestamp) }), - }); - this.checkStatus(response); - return response.status; // Return true if timestamp is updated - } - - checkStatus(response) { - if (response.status >= 200 && response.status < 300) { - return; - } - const error = new Error(`HTTP Error ${response.statusText}`); - error.status = response.statusText; - error.response = response; - throw error; - } -} diff --git a/listenbrainz/webserver/static/js/src/lastFmImporter.jsx b/listenbrainz/webserver/static/js/src/lastFmImporter.jsx deleted file mode 100644 index b81e7c031f..0000000000 --- a/listenbrainz/webserver/static/js/src/lastFmImporter.jsx +++ /dev/null @@ -1,94 +0,0 @@ -/* eslint-disable */ -// TODO: Make the code ESLint compliant -// TODO: Port to typescript - -import ReactDOM from "react-dom"; -import React from "react"; -import Importer from "./importer"; -import Modal from "./lastFmImporterModal"; - -export default class LastFmImporter extends React.Component { - constructor(props) { - super(props); - - this.state = { - show: false, - canClose: true, - lastfmUsername: "", - msg: "", - }; - } - - handleChange = (event) => { - this.setState({ lastfmUsername: event.target.value }); - }; - - handleSubmit = (event) => { - this.toggleModal(); - event.preventDefault(); - this.importer = new Importer(this.state.lastfmUsername, this.props); - setInterval(this.updateMessage, 100); - setInterval(this.setClose, 100); - this.importer.startImport(); - }; - - toggleModal = () => { - this.setState((prevState) => { - return { show: !prevState.show }; - }); - }; - - setClose = () => { - this.setState({ canClose: this.importer.canClose }); - }; - - updateMessage = () => { - this.setState({ msg: this.importer.msg }); - }; - - render() { - return ( -
-
- - -
- {this.state.show && ( - - -
-
-
{this.state.msg}
-
-
- )} -
- ); - } -} - -document.addEventListener("DOMContentLoaded", (event) => { - const domContainer = document.querySelector("#react-container"); - const propsElement = document.getElementById("react-props"); - let reactProps; - try { - reactProps = JSON.parse(propsElement.innerHTML); - } catch (err) { - console.error("Error parsing props:", err); - } - ReactDOM.render(, domContainer); -}); diff --git a/listenbrainz/webserver/static/js/src/lastFmImporterModal.jsx b/listenbrainz/webserver/static/js/src/lastFmImporterModal.jsx deleted file mode 100644 index afba162875..0000000000 --- a/listenbrainz/webserver/static/js/src/lastFmImporterModal.jsx +++ /dev/null @@ -1,48 +0,0 @@ -/* eslint-disable */ -// TODO: Make the code ESLint compliant -// TODO: Port to typescript - -import React from "react"; -import { faTimes } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -export default class Modal extends React.Component { - render() { - const divStyle = { - position: "fixed", - height: "90%", - maxHeight: "300px", - top: "50%", - zIndex: "200000000000000", - width: "90%", - maxWidth: "500px", - left: "50%", - transform: "translate(-50%, -50%)", - backgroundColor: "#fff", - boxShadow: "0 19px 38px rgba(0,0,0,0.30), 0 15px 12px rgba(0,0,0,0.22)", - textAlign: "center", - padding: "50px", - }; - - const buttonStyle = { - position: "absolute", - top: "5px", - right: "10px", - outline: "none", - border: "none", - background: "transparent", - }; - return ( -
- -
{this.props.children}
-
- ); - } -} diff --git a/listenbrainz/webserver/static/js/src/profile.jsx b/listenbrainz/webserver/static/js/src/profile.jsx index 39284cc8d0..97309ac849 100644 --- a/listenbrainz/webserver/static/js/src/profile.jsx +++ b/listenbrainz/webserver/static/js/src/profile.jsx @@ -14,7 +14,7 @@ import { isEqual as _isEqual } from "lodash"; import io from "socket.io-client"; import { SpotifyPlayer } from "./spotify-player"; import { FollowUsers } from "./follow-users"; -import APIService from "./api-service"; +import APIService from "./APIService"; import { getArtistLink, getPlayButton, diff --git a/listenbrainz/webserver/static/js/src/types.d.ts b/listenbrainz/webserver/static/js/src/types.d.ts new file mode 100644 index 0000000000..b34aa788b1 --- /dev/null +++ b/listenbrainz/webserver/static/js/src/types.d.ts @@ -0,0 +1,38 @@ +// TODO: make this type specific +declare type Listen = any; + +declare type ListenType = "single" | "playingNow" | "import"; + +// TODO: make this type specific +declare type SubmitListensPayload = any; + +declare interface ImporterProps { + user: { + id?: string; + name: string; + auth_token: string; + }; + profileUrl?: string; + apiUrl?: string; + lastfmApiUrl: string; + lastfmApiKey: string; +} + +declare interface ImporterState { + show: boolean; + canClose: boolean; + lastfmUsername: string; + msg: string; +} + +declare interface ModalProps { + disable: boolean; + children: React.ReactElement[]; + onClose(event: React.MouseEvent): void; +} + +declare interface LastFmScrobblePage { + recenttracks: { + track: any; + }; +} diff --git a/listenbrainz/webserver/static/js/src/utils.tsx b/listenbrainz/webserver/static/js/src/utils.tsx index 7d6acc3f83..3b94a8e578 100644 --- a/listenbrainz/webserver/static/js/src/utils.tsx +++ b/listenbrainz/webserver/static/js/src/utils.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import _ from "lodash"; import { faPlayCircle } from "@fortawesome/free-solid-svg-icons"; -import { IconProp } from "@fortawesome/fontawesome-svg-core"; // eslint-disable-line no-unused-vars +import { IconProp } from "@fortawesome/fontawesome-svg-core"; const getSpotifyEmbedUriFromListen = (listen: any): string | null => { const spotifyId = _.get(listen, "track_metadata.additional_info.spotify_id"); @@ -110,7 +110,7 @@ const getPlayButton = (listen: any, onClickFunction: () => void) => { onClick={onClickFunction.bind(listen)} type="button" > - + ); }; diff --git a/package-lock.json b/package-lock.json index b1a6154660..40cde072cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1992,6 +1992,15 @@ "@babel/types": "^7.3.0" } }, + "@types/cheerio": { + "version": "0.22.17", + "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.17.tgz", + "integrity": "sha512-izlm+hbqWN9csuB9GSMfCnAyd3/57XZi3rfz1B0C4QBGVMp+9xQ7+9KYnep+ySfUrCWql4lGzkLf0XmprXcz9g==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/clean-css": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@types/clean-css/-/clean-css-4.2.1.tgz", @@ -2007,6 +2016,25 @@ "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", "dev": true }, + "@types/enzyme": { + "version": "3.10.5", + "resolved": "https://registry.npmjs.org/@types/enzyme/-/enzyme-3.10.5.tgz", + "integrity": "sha512-R+phe509UuUYy9Tk0YlSbipRpfVtIzb/9BHn5pTEtjJTF5LXvUjrIQcZvNyANNEyFrd2YGs196PniNT1fgvOQA==", + "dev": true, + "requires": { + "@types/cheerio": "*", + "@types/react": "*" + } + }, + "@types/enzyme-adapter-react-16": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.0.6.tgz", + "integrity": "sha512-VonDkZ15jzqDWL8mPFIQnnLtjwebuL9YnDkqeCDYnB4IVgwUm0mwKkqhrxLL6mb05xm7qqa3IE95m8CZE9imCg==", + "dev": true, + "requires": { + "@types/enzyme": "*" + } + }, "@types/eslint-visitor-keys": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", @@ -2055,6 +2083,16 @@ "@types/istanbul-lib-report": "*" } }, + "@types/jest": { + "version": "25.2.1", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-25.2.1.tgz", + "integrity": "sha512-msra1bCaAeEdkSyA0CZ6gW1ukMIvZ5YoJkdXw/qhQdsuuDlFTcEUrUw8CLCPt2rVRUfXlClVvK2gvPs9IokZaA==", + "dev": true, + "requires": { + "jest-diff": "^25.2.1", + "pretty-format": "^25.2.1" + } + }, "@types/json-schema": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.4.tgz", @@ -3280,6 +3318,15 @@ "node-releases": "^1.1.46" } }, + "bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "requires": { + "fast-json-stable-stringify": "2.x" + } + }, "bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -10010,6 +10057,12 @@ "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=", "dev": true }, + "lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", + "dev": true + }, "lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -10052,6 +10105,12 @@ "semver": "^5.6.0" } }, + "make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, "makeerror": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.11.tgz", @@ -12742,6 +12801,92 @@ "punycode": "^2.1.0" } }, + "ts-jest": { + "version": "25.3.1", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-25.3.1.tgz", + "integrity": "sha512-O53FtKguoMUByalAJW+NWEv7c4tus5ckmhfa7/V0jBb2z8v5rDSLFC1Ate7wLknYPC1euuhY6eJjQq4FtOZrkg==", + "dev": true, + "requires": { + "bs-logger": "0.x", + "buffer-from": "1.x", + "fast-json-stable-stringify": "2.x", + "json5": "2.x", + "lodash.memoize": "4.x", + "make-error": "1.x", + "micromatch": "4.x", + "mkdirp": "1.x", + "resolve": "1.x", + "semver": "6.x", + "yargs-parser": "18.x" + }, + "dependencies": { + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "micromatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", + "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.0.5" + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "yargs-parser": { + "version": "18.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.2.tgz", + "integrity": "sha512-hlIPNR3IzC1YuL1c2UwwDKpXlNFBqD1Fswwh1khz5+d8Cq/8yc/Mn0i+rQXduu8hcrFKvO7Eryk+09NecTQAAQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, "tslib": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.11.1.tgz", diff --git a/package.json b/package.json index b4a495279c..178b7a0164 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,11 @@ "@babel/preset-env": "^7.8.3", "@babel/preset-react": "^7.8.3", "@babel/preset-typescript": "^7.9.0", + "@fortawesome/fontawesome-common-types": "^0.2.28", "@types/clean-css": "^4.2.1", + "@types/enzyme": "^3.10.5", + "@types/enzyme-adapter-react-16": "^1.0.6", + "@types/jest": "^25.2.1", "@types/less": "^3.0.1", "@types/lodash": "^4.14.149", "@types/react": "^16.9.31", @@ -72,9 +76,9 @@ "eslint-plugin-prettier": "^3.1.2", "eslint-plugin-react": "^7.19.0", "eslint-plugin-react-hooks": "^2.5.1", - "@fortawesome/fontawesome-common-types": "^0.2.28", "jest": "^25.1.0", "prettier": "^2.0.2", + "ts-jest": "^25.3.1", "typescript": "^3.8.3", "webpack": "^4.42.1", "webpack-cli": "^3.3.10", diff --git a/tsconfig.json b/tsconfig.json index 1a4d23f296..aa38044a27 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,8 @@ "jsx": "react", "strict": true, "moduleResolution": "node", - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true }, "exclude": [ "node_modules" diff --git a/webpack.config.js b/webpack.config.js index b2c5970474..b53b913fc8 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,22 +1,19 @@ -const path = require('path'); -const ManifestPlugin = require('webpack-manifest-plugin'); -const { CleanWebpackPlugin } = require('clean-webpack-plugin'); +const path = require("path"); +const ManifestPlugin = require("webpack-manifest-plugin"); +const { CleanWebpackPlugin } = require("clean-webpack-plugin"); -module.exports = function(env){ +module.exports = function (env) { const isProd = env === "production"; - const plugins = [ - new CleanWebpackPlugin(), - new ManifestPlugin() - ]; + const plugins = [new CleanWebpackPlugin(), new ManifestPlugin()]; return { mode: isProd ? "production" : "development", entry: { - main: '/static/js/src/profile.jsx', - import: '/static/js/src/lastFmImporter.jsx' + main: "/static/js/src/profile.jsx", + import: "/static/js/src/LastFMImporter.tsx", }, output: { - filename: isProd ? '[name].[contenthash].js' : '[name].js', - path: '/static/js/dist' + filename: isProd ? "[name].[contenthash].js" : "[name].js", + path: "/static/js/dist", }, devtool: isProd ? false : "inline-source-map", module: { @@ -27,33 +24,33 @@ module.exports = function(env){ use: { loader: "babel-loader", options: { - "presets": [ + presets: [ [ "@babel/preset-env", { - "targets": { - "node": "10", - "browsers": [ "> 0.2% and not dead", "firefox >= 44" ] - } - } + targets: { + node: "10", + browsers: ["> 0.2% and not dead", "firefox >= 44"], + }, + }, ], "@babel/preset-typescript", - "@babel/preset-react" + "@babel/preset-react", ], - "plugins": [ + plugins: [ "@babel/plugin-proposal-class-properties", - "@babel/plugin-transform-runtime" - ] - } - } - } + "@babel/plugin-transform-runtime", + ], + }, + }, + }, ], }, resolve: { - modules: ['/code/node_modules', '/static/node_modules'], - extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'] + modules: ["/code/node_modules", "/static/node_modules"], + extensions: [".ts", ".tsx", ".js", ".jsx", ".json"], }, - plugins: plugins, - watch: isProd ? false : true - } + plugins, + watch: !isProd, + }; };