├── .dockerignore
├── .gitignore
├── .travis.yml
├── Dockerfile
├── LICENSE
├── README.md
├── codecov.yaml
├── config-overrides.js
├── docs
├── overview.png
└── shuffle.md
├── nginx.conf
├── package.json
├── public
├── album_placeholder.jpg
├── currently_placeholder.png
├── favicon.ico
├── images
│ └── icons
│ │ ├── icon-128x128.png
│ │ ├── icon-144x144.png
│ │ ├── icon-152x152.png
│ │ ├── icon-192x192.png
│ │ ├── icon-384x384.png
│ │ ├── icon-512x512.png
│ │ ├── icon-72x72.png
│ │ └── icon-96x96.png
├── index.html
└── manifest.json
├── src
├── App.js
├── App.less
├── App.test.js
├── Main.js
├── Main.test.js
├── api
│ └── subsonicApi.js
├── components
│ ├── Album
│ │ ├── Album.js
│ │ ├── Album.less
│ │ ├── Album.test.js
│ │ └── index.js
│ ├── AlbumView
│ │ ├── AlbumView.js
│ │ ├── AlbumView.test.js
│ │ └── index.js
│ ├── AlbumsList
│ │ ├── AlbumsList.js
│ │ ├── AlbumsList.test.js
│ │ ├── NavigationButtons.test.js
│ │ └── index.js
│ ├── AlbumsListFilter
│ │ ├── AlbumsListFilter.js
│ │ ├── AlbumsListFilter.test.js
│ │ └── index.js
│ ├── AlertsManager
│ │ ├── AlertsManager.js
│ │ ├── AlertsManager.test.js
│ │ └── index.js
│ ├── Artist
│ │ ├── Artist.js
│ │ ├── Artist.test.js
│ │ └── index.js
│ ├── ArtistAllSongs
│ │ ├── ArtistAllSongs.js
│ │ ├── ArtistAllSongs.test.js
│ │ └── index.js
│ ├── ArtistByAlbums
│ │ ├── ArtistByAlbums.js
│ │ ├── ArtistByAlbums.less
│ │ ├── ArtistByAlbums.test.js
│ │ └── index.js
│ ├── ArtistListElement
│ │ ├── ArtistListElement.js
│ │ ├── ArtistListElement.less
│ │ ├── ArtistListElement.test.js
│ │ └── index.js
│ ├── ArtistListHeader
│ │ ├── ArtistListHeader.js
│ │ ├── ArtistListHeader.test.js
│ │ └── index.js
│ ├── ArtistListLoader
│ │ ├── ArtistListLoader.js
│ │ ├── ArtistListLoader.test.js
│ │ └── index.js
│ ├── ArtistsList
│ │ ├── ArtistsList.js
│ │ ├── ArtistsList.test.js
│ │ └── index.js
│ ├── AuthenticatedComponent
│ │ ├── AuthenticatedComponent.js
│ │ ├── AuthenticatedComponent.test.js
│ │ └── index.js
│ ├── CreatePlaylistModal
│ │ ├── CreatePlaylistModal.js
│ │ ├── CreatePlaylistModal.test.js
│ │ └── index.js
│ ├── DeletePlaylistModal
│ │ ├── DeletePlaylistModal.js
│ │ ├── DeletePlaylistModal.test.js
│ │ └── index.js
│ ├── EditPlaylistModal
│ │ ├── EditPlaylistModal.js
│ │ ├── EditPlaylistModal.test.js
│ │ └── index.js
│ ├── FavouritesView
│ │ ├── FavouritesView.js
│ │ ├── FavouritesView.test.js
│ │ └── index.js
│ ├── GenreSongs
│ │ ├── GenreSongs.js
│ │ ├── GenreSongs.test.js
│ │ └── index.js
│ ├── GenresPicker
│ │ ├── GenresPicker.js
│ │ ├── GenresPicker.test.js
│ │ └── index.js
│ ├── GenresView
│ │ ├── GenresView.js
│ │ ├── GenresView.test.js
│ │ └── index.js
│ ├── InfiniteLineLoader
│ │ ├── InfiniteLineLoader.js
│ │ ├── InfiniteLineLoader.less
│ │ ├── InfiniteLineLoader.test.js
│ │ └── index.js
│ ├── LoginView
│ │ ├── LoginView.js
│ │ ├── LoginView.test.js
│ │ └── index.js
│ ├── MusicPlayer
│ │ ├── MusicPlayer.js
│ │ ├── MusicPlayer.less
│ │ ├── MusicPlayer.test.js
│ │ └── index.js
│ ├── Navbar
│ │ ├── Navbar.js
│ │ ├── Navbar.test.js
│ │ └── index.js
│ ├── Playlist
│ │ ├── Playlist.js
│ │ ├── Playlist.less
│ │ ├── Playlist.test.js
│ │ └── index.js
│ ├── PlaylistSelectorDropdown
│ │ ├── PlaylistSelectorDropdown.js
│ │ ├── PlaylistSelectorDropdown.test.js
│ │ └── index.js
│ ├── QueueView
│ │ ├── QueueView.js
│ │ ├── QueueView.less
│ │ ├── QueueView.test.js
│ │ └── index.js
│ ├── RecentlyAddedView
│ │ ├── RecentlyAddedView.js
│ │ ├── RecentlyAddedView.less
│ │ ├── RecentlyAddedView.test.js
│ │ └── index.js
│ ├── ResponsiveTitle
│ │ ├── ResponsiveTitle.js
│ │ ├── ResponsiveTitle.test.js
│ │ └── index.js
│ ├── ScrobbleSetting
│ │ ├── ScrobbleSetting.js
│ │ ├── ScrobbleSetting.test.js
│ │ └── index.js
│ ├── SearchAlbumResult
│ │ ├── SearchAlbumResult.js
│ │ ├── SearchAlbumResult.less
│ │ ├── SearchAlbumResult.test.js
│ │ └── index.js
│ ├── SearchBar
│ │ ├── SearchBar.js
│ │ ├── SearchBar.test.js
│ │ └── index.js
│ ├── SearchSongResult
│ │ ├── SearchSongResult.js
│ │ ├── SearchSongResult.test.js
│ │ └── index.js
│ ├── SearchView
│ │ ├── SearchView.js
│ │ ├── SearchView.test.js
│ │ └── index.js
│ ├── SettingsView
│ │ ├── SettingsView.js
│ │ ├── SettingsView.test.js
│ │ └── index.js
│ ├── Sidebar
│ │ ├── Sidebar.js
│ │ ├── Sidebar.less
│ │ ├── Sidebar.test.js
│ │ └── index.js
│ ├── SidebarSettings
│ │ ├── SidebarSettings.js
│ │ ├── SidebarSettings.test.js
│ │ └── index.js
│ ├── SongsTable
│ │ ├── SongsTable.js
│ │ ├── SongsTable.less
│ │ ├── SongsTable.test.js
│ │ └── index.js
│ ├── SongsTableEnhanced
│ │ ├── SongsTableEnhanced.js
│ │ ├── SongsTableEnhanced.test.js
│ │ └── index.js
│ └── ThemePicker
│ │ ├── ThemePicker.js
│ │ ├── ThemePicker.less
│ │ ├── ThemePicker.test.js
│ │ └── index.js
├── index.js
├── redux
│ ├── actions
│ │ ├── _tests_
│ │ │ ├── albumActions.test.js
│ │ │ ├── apiStatusActions.test.js
│ │ │ ├── artistsActions.test.js
│ │ │ ├── authActions.test.js
│ │ │ ├── favouritesActions.test.js
│ │ │ ├── genresActions.test.js
│ │ │ ├── playlistsActions.test.js
│ │ │ ├── searchActions.test.js
│ │ │ └── songsActions.test.js
│ │ ├── actionTypes.js
│ │ ├── albumActions.js
│ │ ├── alertsActions.js
│ │ ├── apiStatusActions.js
│ │ ├── artistsActions.js
│ │ ├── authActions.js
│ │ ├── favouritesActions.js
│ │ ├── genresActions.js
│ │ ├── playlistsActions.js
│ │ ├── searchActions.js
│ │ └── songsActions.js
│ ├── configureStore.dev.js
│ ├── configureStore.js
│ ├── configureStore.prod.js
│ ├── reducers
│ │ ├── _tests_
│ │ │ ├── albumsReducer.test.js
│ │ │ ├── alertsReducer.test.js
│ │ │ ├── artistsReducer.test.js
│ │ │ ├── asyncTasksReducer.test.js
│ │ │ ├── authReducer.test.js
│ │ │ ├── favouritesReducer.test.js
│ │ │ ├── musicPlayerReducer.test.js
│ │ │ ├── playlistsReducer.test.js
│ │ │ ├── searchReducer.test.js
│ │ │ └── songsReducer.test.js
│ │ ├── albumsReducer.js
│ │ ├── alertsReducer.js
│ │ ├── artistsReducer.js
│ │ ├── asyncTasksReducer.js
│ │ ├── authReducer.js
│ │ ├── favouritesReducer.js
│ │ ├── index.js
│ │ ├── initialState.js
│ │ ├── musicPlayerReducer.js
│ │ ├── playlistsReducer.js
│ │ ├── searchReducer.js
│ │ └── songsReducer.js
│ └── selectors
│ │ ├── _tests_
│ │ ├── albumSelectors.test.js
│ │ ├── artistSelectors.test.js
│ │ ├── musicPlayerSelector.test.js
│ │ ├── searchSelectors.test.js
│ │ └── songSelectors.test.js
│ │ ├── albumSelectors.js
│ │ ├── artistSelectors.js
│ │ ├── musicPlayerSelector.js
│ │ ├── searchSelectors.js
│ │ └── songSelectors.js
├── serviceWorker.js
├── setupTests.js
└── utils
│ ├── formatting.js
│ ├── redux.js
│ ├── settings.js
│ ├── theming.js
│ └── utils.js
├── themes.config.js
├── themes.content.js
└── yarn.lock
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .git
3 | .gitignore
4 | build
5 | README.md
6 | docs
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | # Sublime stuff
26 | *.sublime-project
27 | *.sublime-workspace
28 |
29 | # Compiled themes
30 | /src/less/*
31 | /src/themes.config.js
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - node
4 | - lts/*
5 | install:
6 | - yarn global add codecov
7 | - yarn
8 | script:
9 | - yarn test --coverage --watchAll=false && codecov
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 |
2 | # Stage 1
3 | FROM node:lts as react-build
4 | WORKDIR /app
5 | COPY . ./
6 | RUN yarn
7 | RUN yarn build
8 |
9 | # Stage 2 - the production environment
10 | FROM nginx:alpine
11 | ENV NODE_ENV="production"
12 | COPY --from=react-build /app/build /usr/share/nginx/html
13 | RUN rm /etc/nginx/conf.d/default.conf
14 | COPY nginx.conf /etc/nginx/conf.d
15 | EXPOSE 80
16 | CMD ["nginx", "-g", "daemon off;"]
--------------------------------------------------------------------------------
/codecov.yaml:
--------------------------------------------------------------------------------
1 | codecov:
2 | require_ci_to_pass: yes
3 |
4 | coverage:
5 | precision: 2
6 | round: down
7 | range: "70...100"
8 |
9 | parsers:
10 | gcov:
11 | branch_detection:
12 | conditional: yes
13 | loop: yes
14 | method: no
15 | macro: no
16 |
17 | comment:
18 | layout: "reach,diff,flags,tree"
19 | behavior: default
20 | require_changes: no
21 |
--------------------------------------------------------------------------------
/config-overrides.js:
--------------------------------------------------------------------------------
1 | /* config-overrides.js */
2 | const path = require('path');
3 | const themes = require('./themes.config');
4 | const themeContent = require('./themes.content');
5 |
6 | const merge = require('webpack-merge');
7 | const multipleThemesCompile = require('webpack-multiple-themes-compile/src');
8 | const multipleEntry = require('react-app-rewire-multiple-entry')
9 |
10 |
11 | // Validate themes before compiling
12 | function validateThemes(themes) {
13 | // Check that themes start with "light" or "dark"
14 | const themeNames = Object.keys(themes)
15 | for (var i = 0; i < themeNames.length; i++) {
16 | const name = themeNames[i]
17 | if( !(name.startsWith("light") || name.startsWith("dark")) ) {
18 | throw new Error("Invalid themes. Please check README for details.")
19 | }
20 | }
21 | console.log("Valid themes found.")
22 | }
23 | validateThemes(themes)
24 |
25 |
26 | module.exports = {
27 | webpack: function(config, env) {
28 | // This is to re-format CRA's entries to make them compatible with multipleThemesCompile()
29 | const appEntries = multipleEntry([{entry : 'src/index.js'}])
30 | appEntries.addMultiEntry(config)
31 | // Add theming
32 | let multiTheme = multipleThemesCompile({
33 | themesConfig: themes,
34 | lessContent: (themeName, config) => themeContent(themeName),
35 | styleLoaders: [
36 | { loader: 'css-loader' },
37 | {
38 | loader: 'less-loader',
39 | options: {
40 | lessOptions: {
41 | javascriptEnabled: true
42 | }
43 | }
44 | }
45 | ],
46 | cwd: path.resolve('./')
47 | })
48 |
49 | /* Store the rule to load less/css files to put it where CRA stores these kind of rules
50 | * (otherwise, duplicate rules with existing and the compiler will complain) */
51 | var lessRule = multiTheme["module"]["rules"][0]
52 | delete multiTheme["module"]["rules"]
53 |
54 | // Change the location of css files to import them by name without knowing the id
55 | multiTheme["plugins"][0]["options"]["chunkFilename"] = "css/[name].css"
56 |
57 | // Merge default CRA config with themes' config. Now, we just need to fix the lessRule.
58 | var newConfig = merge(config, multiTheme)
59 |
60 | // Add themes' rule to the beginning of existing 'oneOf' to make it the first to be applied
61 | newConfig["module"]["rules"][2]["oneOf"].unshift(lessRule)
62 |
63 | return newConfig
64 | }
65 | };
--------------------------------------------------------------------------------
/docs/overview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/docs/overview.png
--------------------------------------------------------------------------------
/docs/shuffle.md:
--------------------------------------------------------------------------------
1 | # Shuffle Behaviour
2 |
3 | ## Desired beaviour
4 |
5 | If a song is already playing, when:
6 | * turning shuffle on: the song currently playing goes to the front of the queue and the rest of the songs are shuffled (even if they were already played)
7 | * turning shuffle off: the song currently playing goes to the front of the queue and the rest is filled only with the songs that came after in the original order
8 |
9 | When playing a new list, if:
10 | * the shuffle is on: everything is shuffled and a random song is played.
11 | * the shuffle is off: the list starts reproducing from top to bottom.
12 |
13 | ## Implementation
14 |
15 | The status is saved in the store as:
16 | ```
17 | {
18 | songsById: {},
19 | original : [id1, id2, id3, id4, id5],
20 | currentSongId: id1 /* this is the one that matters when updating the state of the views */
21 | currentSongIndex : 3,
22 | queue : [id3, id4, id1, id2, id5],
23 | isShuffleOn : true,
24 | }
25 | ```
26 |
27 | If shuffle is switched on:
28 | ```
29 | {
30 | songsById: /* doesn't change */,
31 | original: /* doesn't change */,
32 | currentSongId: /* doesn't change */,
33 | currentSongIndex: /* is now */ 0,
34 | queue: /* is now: */ [currentSongId, the original queue (except currentSongId) shuffled ],
35 | isShuffleOn: true
36 | }
37 | ```
38 |
39 | If shuffle is switched off:
40 | ```
41 | {
42 | songsById: /* doesn't change */,
43 | original: /* doesn't change */,
44 | currentSongId: /* doesn't change */,
45 | currentSongIndex: /* is now */ 0,
46 | queue: /* is now: */ [currentSongId, the original queue starting from currentSongId ],
47 | isShuffleOn: false
48 | }
49 | ```
50 |
--------------------------------------------------------------------------------
/nginx.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | #server_name localhost;
4 | #charset koi8-r;
5 | #access_log /var/log/nginx/host.access.log main;
6 |
7 | location / {
8 | root /usr/share/nginx/html;
9 | index index.html index.htm;
10 | try_files $uri /index.html;
11 | }
12 |
13 | #error_page 404 /404.html;
14 |
15 | # redirect server error pages to the static page /50x.html
16 | #
17 | error_page 500 502 503 504 /50x.html;
18 | location = /50x.html {
19 | root /usr/share/nginx/html;
20 | }
21 |
22 | # proxy the PHP scripts to Apache listening on 127.0.0.1:80
23 | #
24 | #location ~ \.php$ {
25 | # proxy_pass http://127.0.0.1;
26 | #}
27 |
28 | # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
29 | #
30 | #location ~ \.php$ {
31 | # root html;
32 | # fastcgi_pass 127.0.0.1:9000;
33 | # fastcgi_index index.php;
34 | # fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
35 | # include fastcgi_params;
36 | #}
37 |
38 | # deny access to .htaccess files, if Apache's document root
39 | # concurs with nginx's one
40 | #
41 | #location ~ /\.ht {
42 | # deny all;
43 | #}
44 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "subsonic-player",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@reach/router": "^1.2.1",
7 | "@welldone-software/why-did-you-render": "^3.2.3",
8 | "customize-cra": "^0.4.1",
9 | "enzyme": "^3.10.0",
10 | "enzyme-adapter-react-16": "^1.14.0",
11 | "fetch-mock": "^7.3.9",
12 | "gensync": "^1.0.0-beta.1",
13 | "howler": "^2.1.2",
14 | "less": "^3.9.0",
15 | "less-loader": "6.1.2",
16 | "rc-slider": "^9.5.4",
17 | "react": "^16.8.6",
18 | "react-app-rewire-multiple-entry": "^2.2.0",
19 | "react-app-rewired": "^2.1.3",
20 | "react-dom": "^16.8.6",
21 | "react-infinite-scroller": "^1.2.4",
22 | "react-redux": "^7.1.0",
23 | "react-scripts": "3.0.1",
24 | "react-virtualized-auto-sizer": "^1.0.2",
25 | "redux": "^4.0.4",
26 | "redux-immutable-state-invariant": "^2.1.0",
27 | "redux-mock-store": "^1.5.3",
28 | "redux-thunk": "^2.3.0",
29 | "reselect": "^4.0.0",
30 | "rsuite": "4.7.2",
31 | "webpack-merge": "^4.2.2",
32 | "webpack-multiple-themes-compile": "^2.0.0"
33 | },
34 | "scripts": {
35 | "start": "(rm -r src/less || true ) && (cp themes.config.js src/themes.config.js) && react-app-rewired start",
36 | "build": "(rm -r src/less || true ) && (cp themes.config.js src/themes.config.js) && react-app-rewired build",
37 | "test": "(rm -r src/less || true ) && (cp themes.config.js src/themes.config.js) && react-app-rewired test --env=jsdom",
38 | "eject": "react-app-rewired eject"
39 | },
40 | "eslintConfig": {
41 | "extends": "react-app"
42 | },
43 | "browserslist": {
44 | "production": [
45 | ">0.2%",
46 | "not dead",
47 | "not op_mini all"
48 | ],
49 | "development": [
50 | "last 1 chrome version",
51 | "last 1 firefox version",
52 | "last 1 safari version"
53 | ]
54 | },
55 | "jest": {
56 | "collectCoverageFrom": [
57 | "src/**/*.{js,jsx}",
58 | "!src/components/**/index.js",
59 | "!src/**/*.dev.js",
60 | "!/node_modules/",
61 | "!src/index.js",
62 | "!src/serviceWorker.js"
63 | ]
64 | },
65 | "devDependencies": {
66 | "jest-react-hooks-shallow": "^1.4.1"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/public/album_placeholder.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/album_placeholder.jpg
--------------------------------------------------------------------------------
/public/currently_placeholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/currently_placeholder.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/favicon.ico
--------------------------------------------------------------------------------
/public/images/icons/icon-128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/images/icons/icon-128x128.png
--------------------------------------------------------------------------------
/public/images/icons/icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/images/icons/icon-144x144.png
--------------------------------------------------------------------------------
/public/images/icons/icon-152x152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/images/icons/icon-152x152.png
--------------------------------------------------------------------------------
/public/images/icons/icon-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/images/icons/icon-192x192.png
--------------------------------------------------------------------------------
/public/images/icons/icon-384x384.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/images/icons/icon-384x384.png
--------------------------------------------------------------------------------
/public/images/icons/icon-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/images/icons/icon-512x512.png
--------------------------------------------------------------------------------
/public/images/icons/icon-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/images/icons/icon-72x72.png
--------------------------------------------------------------------------------
/public/images/icons/icon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/images/icons/icon-96x96.png
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
21 | SubPlayer
22 |
23 |
24 |
25 |
26 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "SubPlayer",
3 | "short_name": "SubPlayer",
4 | "theme_color": "rgb(29,45,60)",
5 | "background_color": "#fa2755",
6 | "display": "standalone",
7 | "orientation": "portrait",
8 | "start_url": "/",
9 | "icons": [
10 | {
11 | "src": "images/icons/icon-72x72.png",
12 | "sizes": "72x72",
13 | "type": "image/png"
14 | },
15 | {
16 | "src": "images/icons/icon-96x96.png",
17 | "sizes": "96x96",
18 | "type": "image/png"
19 | },
20 | {
21 | "src": "images/icons/icon-128x128.png",
22 | "sizes": "128x128",
23 | "type": "image/png"
24 | },
25 | {
26 | "src": "images/icons/icon-144x144.png",
27 | "sizes": "144x144",
28 | "type": "image/png"
29 | },
30 | {
31 | "src": "images/icons/icon-152x152.png",
32 | "sizes": "152x152",
33 | "type": "image/png"
34 | },
35 | {
36 | "src": "images/icons/icon-192x192.png",
37 | "sizes": "192x192",
38 | "type": "image/png"
39 | },
40 | {
41 | "src": "images/icons/icon-384x384.png",
42 | "sizes": "384x384",
43 | "type": "image/png"
44 | },
45 | {
46 | "src": "images/icons/icon-512x512.png",
47 | "sizes": "512x512",
48 | "type": "image/png"
49 | }
50 | ],
51 | "splash_pages": null
52 | }
--------------------------------------------------------------------------------
/src/App.less:
--------------------------------------------------------------------------------
1 | @scrollbar-width: 8px;
2 |
3 | /*Scrollbar*/
4 | &::-webkit-scrollbar {
5 | border-radius: (@scrollbar-width / 2);
6 | width: @scrollbar-width;
7 | }
8 |
9 | /*Handle*/
10 | &::-webkit-scrollbar-thumb {
11 | border-radius: (@scrollbar-width / 2);
12 | background-color: fade(#8d8d8d, 30);
13 | &:hover {
14 | background-color: fade(#8d8d8d, 90);
15 | }
16 | }
17 |
18 | /*Track*/
19 | &::-webkit-scrollbar-track {
20 | border-radius: (@scrollbar-width / 2);
21 | background: transparent;
22 | }
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import {App} from "./App"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing", () => {
8 | shallow( null} logout={() => null} /> )
9 | })
10 |
11 | it("starts loading the list of playlists to display when mounted", () => {
12 | const loadPlaylists = jest.fn()
13 | const wrapper = shallow( null} /> )
14 | expect(loadPlaylists).toHaveBeenCalledTimes(1)
15 | })
16 |
17 | it("shows a loader when async tasks are pending", () => {
18 | const wrapper = shallow( null} logout={() => null} asyncTasksInProgress={5} /> )
19 | expect(wrapper.find("#loader").prop("isLoading")).toBeTruthy()
20 | })
21 |
22 | it("should show the playlist creation modal when selected from the navbar", () => {
23 | const wrapper = shallow( null} logout={() => null} /> )
24 | wrapper.find("#mobileNavbar").simulate("createPlaylistTrigger")
25 | expect(wrapper.find("#createPlaylistModal").prop("showModal")).toBeTruthy()
26 | })
27 |
28 | it("should show the playlist creation modal when selected from the side bar", () => {
29 | const wrapper = shallow( null} logout={() => null} /> )
30 | wrapper.find("#sidebar").simulate("createPlaylistTrigger")
31 | expect(wrapper.find("#createPlaylistModal").prop("showModal")).toBeTruthy()
32 | })
33 |
34 | it("should hide the playlist creation modal when commanded", () => {
35 | const wrapper = shallow( null} logout={() => null} /> )
36 | // First, show the modal
37 | wrapper.find("#sidebar").simulate("createPlaylistTrigger")
38 | // Now, close it
39 | wrapper.find("#createPlaylistModal").simulate("closePlaylistModal")
40 | expect(wrapper.find("#createPlaylistModal").prop("showModal")).toBeFalsy()
41 | })
42 |
43 |
44 | })
--------------------------------------------------------------------------------
/src/Main.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import App from './App'
3 | import { Router } from "@reach/router"
4 | // Redux imports
5 | import { Provider } from 'react-redux'
6 | import configureStore from "./redux/configureStore"
7 | // My components
8 | import AuthenticatedComponent from './components/AuthenticatedComponent'
9 | import Login from './components/LoginView'
10 |
11 | // Default components
12 | const NotFound = () => 404! Sorry, nothing here
13 | // Init app
14 | const store = configureStore()
15 |
16 | export default (props) => (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | )
--------------------------------------------------------------------------------
/src/Main.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import Main from './Main'
4 |
5 | it('renders without crashing', () => {
6 | const div = document.createElement('div')
7 | ReactDOM.render(, div)
8 | ReactDOM.unmountComponentAtNode(div)
9 | })
10 |
--------------------------------------------------------------------------------
/src/components/Album/Album.less:
--------------------------------------------------------------------------------
1 | // https://stackoverflow.com/questions/9940016/how-to-space-text-like-using-tab-on-website
2 | .album-panel p strong {
3 | display: inline-block;
4 | *display: inline;
5 | zoom: 1;
6 | width: 50px;
7 | margin-left: 10px;
8 | }
9 |
10 | .album-panel .artist-link {
11 | cursor: pointer;
12 | font-weight: bold;
13 | }
14 |
15 | .album-panel .artist-link:hover {
16 | text-decoration: underline;
17 | }
--------------------------------------------------------------------------------
/src/components/Album/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { addSongsToPlaylist } from "../../redux/actions/playlistsActions"
4 | import { setStarOnAlbums } from "../../redux/actions/albumActions"
5 | import { makeGetSongsOfAlbum } from '../../redux/selectors/songSelectors'
6 | // UI
7 | import Album from './Album'
8 |
9 | const mapStateToProps = (state, ownProps) => {
10 | const getSongsOfAlbum = makeGetSongsOfAlbum()
11 | return {
12 | "album" : state.albums.byId[ownProps.albumId],
13 | "songs" : getSongsOfAlbum(state, ownProps),
14 | }
15 | }
16 |
17 | const mapDispatchToProps = { addSongsToPlaylist, starAlbums: setStarOnAlbums }
18 |
19 | export default connect(mapStateToProps, mapDispatchToProps)(Album)
--------------------------------------------------------------------------------
/src/components/AlbumView/AlbumView.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import PropTypes from 'prop-types'
3 | // UI
4 | import Album from '../Album'
5 |
6 | export default class AlbumView extends React.PureComponent {
7 |
8 | componentDidMount() {
9 | this.props.loadAlbum(this.props.albumId)
10 | }
11 |
12 | render() {
13 | const albumId = this.props.albumId
14 | return (
15 |
18 | )
19 | }
20 | }
21 |
22 | AlbumView.propTypes = {
23 | loadAlbum : PropTypes.func.isRequired,
24 | albumId : PropTypes.string.isRequired,
25 | }
26 |
27 | AlbumView.defaultProps = {
28 | loadAlbum : () => {},
29 | albumId : null,
30 | }
--------------------------------------------------------------------------------
/src/components/AlbumView/AlbumView.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import AlbumView from "./AlbumView"
4 |
5 |
6 | function setup() {
7 |
8 | const props = {
9 | loadAlbum: jest.fn(),
10 | albumId : "album_id"
11 | }
12 |
13 | return {props, albumView : }
14 | }
15 |
16 | describe("", () => {
17 |
18 | it("renders without crashing", () => {
19 | const { albumView } = setup()
20 | shallow( albumView )
21 | })
22 |
23 | it("should start loading album when mounting", () => {
24 | const {props, albumView} = setup()
25 | const enzymeWrapper = shallow( albumView )
26 | expect( props.loadAlbum ).toHaveBeenCalled()
27 | })
28 |
29 | })
--------------------------------------------------------------------------------
/src/components/AlbumView/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { loadAlbum } from "../../redux/actions/albumActions"
4 | // UI
5 | import AlbumView from './AlbumView'
6 |
7 | const mapDispatchToProps = { loadAlbum }
8 |
9 | export default connect(null, mapDispatchToProps)(AlbumView)
--------------------------------------------------------------------------------
/src/components/AlbumsList/AlbumsList.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow } from 'enzyme'
3 | import {AlbumsList} from "./AlbumsList"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing and loads albums", () => {
8 | const loadAlbums = jest.fn()
9 | shallow( )
10 | expect(loadAlbums).toHaveBeenCalledTimes(1)
11 | })
12 |
13 | it("renders a list of albums without crashing", () => {
14 | const albums = [{id:1}, {id:2}]
15 | shallow( null} albums={[]} /> )
16 | })
17 |
18 | it("re-loads albums when a filter changes", () => {
19 | const loadAlbums = jest.fn()
20 | const filter = "new filter"
21 | const wrapper = shallow( )
22 | wrapper.find("#albumsFilter").simulate("filterChanged", {filter})
23 | expect(loadAlbums).toHaveBeenLastCalledWith(filter, {}, 0)
24 | })
25 |
26 | it("loads albums when the next page is clicked (both for MD and mobile screens)", () => {
27 | const loadAlbums = jest.fn()
28 | const filter = "new filter"
29 | const wrapper = shallow( )
30 | wrapper.find("#pageNavigation").simulate("next")
31 | wrapper.find("#pageNavigationMobile").simulate("next")
32 | // 3 times: 1 on first render, and other 2 when clicking both "next"s
33 | expect(loadAlbums).toHaveBeenCalledTimes(3)
34 | })
35 |
36 | it("loads albums when the previous page is clicked (both for MD and mobile screens)", () => {
37 | const loadAlbums = jest.fn()
38 | const filter = "new filter"
39 | const wrapper = shallow( )
40 | wrapper.find("#pageNavigation").simulate("previous")
41 | wrapper.find("#pageNavigationMobile").simulate("previous")
42 | // 3 times: 1 on first render, and other 2 when clicking both "next"s
43 | expect(loadAlbums).toHaveBeenCalledTimes(3)
44 | })
45 |
46 | })
--------------------------------------------------------------------------------
/src/components/AlbumsList/NavigationButtons.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import {NavigationButtons} from "./AlbumsList"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing", () => {
8 | shallow( )
9 | })
10 |
11 | })
--------------------------------------------------------------------------------
/src/components/AlbumsList/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { loadAlbums } from "../../redux/actions/albumActions"
4 | import { albumsSelector } from "../../redux/selectors/albumSelectors"
5 | // UI
6 | import {AlbumsList} from "./AlbumsList"
7 |
8 | const mapStateToProps = (state, ownProps) => {
9 | return {
10 | "albums" : albumsSelector(state)
11 | }
12 | }
13 |
14 | const mapDispatchToProps = { loadAlbums }
15 |
16 | export default connect(mapStateToProps, mapDispatchToProps)(AlbumsList)
--------------------------------------------------------------------------------
/src/components/AlbumsListFilter/AlbumsListFilter.js:
--------------------------------------------------------------------------------
1 | import React, {useState} from "react"
2 | // UI
3 | import {RadioGroup, Radio, InputNumber, Button, Alert } from 'rsuite'
4 | import GenresPicker from "../GenresPicker"
5 |
6 | export default function AlbumsListFilter(props) {
7 | // Filter details
8 | const [yearFrom, setYearFrom] = useState(null)
9 | const [yearTo, setYearTo] = useState(null)
10 |
11 | // Filters selection
12 | const [filter, setFilter] = useState("newest")
13 |
14 | // Change the settings when the radio option has changed
15 | function onRadioChanged(value) {
16 | setFilter(value)
17 | // Show the years input
18 | if( value !== "byYear" && value !== "byGenre") {
19 | props.onFilterChanged({ filter: value })
20 | }
21 | }
22 |
23 | // On genre changed
24 | function onGenreChanged(genre) {
25 | if( genre !== null) {
26 | props.onFilterChanged({ filter: filter, genre:genre.value })
27 | }
28 | }
29 |
30 | // WHen the search button is pressed
31 | function onSearch() {
32 | if( filter === "byYear") {
33 | if( yearFrom === null || yearTo === null ) {
34 | Alert.warning("Both year values are required")
35 | }
36 | else {
37 | props.onFilterChanged({ filter: filter, from: yearFrom, to: yearTo })
38 | }
39 | }
40 | }
41 |
42 | // render
43 | const showYears = filter === "byYear"
44 | const showGenres = filter === "byGenre"
45 | return (
46 |
47 |
48 | Random
49 | Newest
50 | Frequent
51 | Recent
52 | Starred
53 | By Name
54 | By Artist
55 | By Year
56 | By Genre
57 |
58 | {showGenres && }
59 | {showYears && setYearFrom(val)} placeholder="from" style={{ width: 80, marginLeft:"10px"}}/> }
60 | {showYears && setYearTo(val)} placeholder="to" style={{ width: 80, marginLeft:"10px"}}/> }
61 | {showYears && }
62 |
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/src/components/AlbumsListFilter/AlbumsListFilter.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import AlbumsListFilter from "./AlbumsListFilter"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing", () => {
8 | shallow( )
9 | })
10 |
11 | it("notifies when a basic filter is choosed", () => {
12 | const onFilterChanged = jest.fn()
13 | const filter = "newest"
14 | const wrapper = shallow( )
15 | wrapper.find("#filterSelection").simulate("change", filter)
16 | expect(onFilterChanged).toHaveBeenCalledWith({filter: filter})
17 | })
18 |
19 | it("notifies when a genre is selected", () => {
20 | const onFilterChanged = jest.fn()
21 | const filter = "byGenre"
22 | const genre = {value: "blues"}
23 | const wrapper = shallow( )
24 | wrapper.find("#filterSelection").simulate("change", filter)
25 | wrapper.find("#genrePicker").simulate("genreChanged", genre)
26 | expect(onFilterChanged).toHaveBeenCalledWith({filter: filter, genre: "blues"})
27 | })
28 |
29 | it("should only notify if a genre is set", () => {
30 | const onFilterChanged = jest.fn()
31 | const filter = "byGenre"
32 | const wrapper = shallow( )
33 | wrapper.find("#filterSelection").simulate("change", filter)
34 | wrapper.find("#genrePicker").simulate("genreChanged", null)
35 | expect(onFilterChanged).toHaveBeenCalledTimes(0)
36 | })
37 |
38 | it("notifies when the filter is set by year", () => {
39 | const onFilterChanged = jest.fn()
40 | const filter = "byYear"
41 | const wrapper = shallow( )
42 | wrapper.find("#filterSelection").simulate("change", filter)
43 | wrapper.find("#yearFrom").simulate("change","1990")
44 | wrapper.find("#yearTo").simulate("change","1995")
45 | wrapper.find("#yearSearch").simulate("click")
46 | expect(onFilterChanged).toHaveBeenCalledWith({filter: filter, from:"1990", to:"1995"})
47 | })
48 |
49 | it("should only notify if both years are set", () => {
50 | const onFilterChanged = jest.fn()
51 | const filter = "byYear"
52 | const wrapper = shallow( )
53 | wrapper.find("#filterSelection").simulate("change", filter)
54 | wrapper.find("#yearFrom").simulate("change","1990")
55 | wrapper.find("#yearSearch").simulate("click")
56 | expect(onFilterChanged).toHaveBeenCalledTimes(0)
57 | })
58 |
59 | })
--------------------------------------------------------------------------------
/src/components/AlbumsListFilter/index.js:
--------------------------------------------------------------------------------
1 | // UI
2 | import AlbumsListFilter from './AlbumsListFilter'
3 |
4 | export default AlbumsListFilter
--------------------------------------------------------------------------------
/src/components/AlertsManager/AlertsManager.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import PropTypes from 'prop-types'
3 | import * as alerts from "../../redux/actions/alertsActions"
4 | // UI
5 | import { Alert } from 'rsuite'
6 |
7 | export default class AlertsManager extends React.Component {
8 |
9 | componentDidUpdate(prevProps) {
10 | // Only show alerts if there is a new alert pending to show
11 | const alertToShow = this.props.alertToShow
12 | if( prevProps.alertToShow.id !== alertToShow.id){
13 | // Check which is the correct case of alert to display
14 | switch(alertToShow.type) {
15 | case alerts.ALERT_TYPE_SUCCESS:
16 | Alert.success(alertToShow.message)
17 | break
18 | case alerts.ALERT_TYPE_WARNING:
19 | Alert.warning(alertToShow.message)
20 | break
21 | default:
22 | Alert.error(alertToShow.message)
23 | }
24 | }
25 | }
26 |
27 | render = () => null
28 | }
29 |
30 | AlertsManager.propTypes = {
31 | alertToShow : PropTypes.object
32 | }
--------------------------------------------------------------------------------
/src/components/AlertsManager/AlertsManager.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import AlertsManager from "./AlertsManager"
4 | import * as alerts from "../../redux/actions/alertsActions"
5 | import { Alert } from 'rsuite'
6 |
7 | describe("", () => {
8 |
9 | it("renders without crashing", () => {
10 | shallow( )
11 | })
12 |
13 | it("should show a success Alert", () => {
14 | const alertToShow = {
15 | id : 1,
16 | type : alerts.ALERT_TYPE_SUCCESS,
17 | message : "message"
18 | }
19 | Alert.success = jest.fn()
20 | const enzymeWrapper = shallow( )
21 | enzymeWrapper.setProps({alertToShow})
22 | expect(Alert.success).toHaveBeenCalled()
23 | })
24 |
25 | it("should show a warning Alert", () => {
26 | const alertToShow = {
27 | id : 1,
28 | type : alerts.ALERT_TYPE_WARNING,
29 | message : "message"
30 | }
31 | Alert.warning = jest.fn()
32 | const enzymeWrapper = shallow( )
33 | enzymeWrapper.setProps({alertToShow})
34 | expect(Alert.warning).toHaveBeenCalled()
35 | })
36 |
37 | it("should show an error Alert", () => {
38 | const alertToShow = {
39 | id : 1,
40 | type : alerts.ALERT_TYPE_ERROR,
41 | message : "message"
42 | }
43 | Alert.error = jest.fn()
44 | const enzymeWrapper = shallow( )
45 | enzymeWrapper.setProps({alertToShow})
46 | expect(Alert.error).toHaveBeenCalled()
47 | })
48 |
49 | })
--------------------------------------------------------------------------------
/src/components/AlertsManager/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | // UI
4 | import AlertsManager from './AlertsManager'
5 |
6 | const mapStateToProps = (state) => {
7 | return {
8 | alertToShow : state.alert
9 | }
10 | }
11 |
12 | export default connect(
13 | mapStateToProps,
14 | null
15 | )(AlertsManager)
--------------------------------------------------------------------------------
/src/components/Artist/Artist.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import PropTypes from 'prop-types'
3 | // UI
4 | import { Nav, Icon } from 'rsuite'
5 | import ArtistAllSongs from '../ArtistAllSongs'
6 | import ArtistByAlbums from '../ArtistByAlbums'
7 | import ResponsiveTitle from '../ResponsiveTitle'
8 |
9 | export default class Artist extends React.Component {
10 |
11 | constructor(props) {
12 | super(props)
13 | this.state = {selectedView : Artist.KEY_ALL_SONGS}
14 | }
15 |
16 | componentDidMount() {
17 | this.props.loadOneArtist(this.props.artistId)
18 | }
19 |
20 | onViewSelected = (viewId) => {
21 | this.setState({selectedView : viewId})
22 | }
23 |
24 | render() {
25 | const artist = this.props.artist || {}
26 | const activeView = this.state.selectedView
27 | return (
28 |
29 |
30 | {artist != null ? artist.name : "..."}
31 |
35 |
36 | { activeView === Artist.KEY_BY_ALBUM &&
}
37 | { activeView === Artist.KEY_ALL_SONGS &&
}
38 |
39 | )
40 | }
41 | }
42 |
43 | Artist.propTypes = {
44 | artistId : PropTypes.string.isRequired,
45 | artist : PropTypes.object,
46 | loadOneArtist : PropTypes.func.isRequired
47 | }
48 |
49 | Artist.defaultProps = {
50 | artist : {},
51 | }
52 |
53 | Artist.KEY_ALL_SONGS = "all"
54 | Artist.KEY_BY_ALBUM = "by_album"
--------------------------------------------------------------------------------
/src/components/Artist/Artist.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import Artist from "./Artist"
4 |
5 | const props = {
6 | artist : {
7 | album: ["album1", "album2"],
8 | albumCount: 2,
9 | id: "b44b14b2-0bfb-4a45-a36a-5a063568dddc",
10 | name: "Alabama Shakes",
11 | },
12 | artistId : "b44b14b2-0bfb-4a45-a36a-5a063568dddc",
13 | loadOneArtist : () => null
14 | }
15 |
16 | describe("", () => {
17 |
18 | it("renders without crashing", () => {
19 | shallow( )
20 | })
21 |
22 | it("should load the artist's data when mounting", () => {
23 | const loadOneArtist = jest.fn()
24 | const enzymeWrapper = shallow( )
25 | expect(loadOneArtist).toHaveBeenCalled()
26 | })
27 |
28 | it("should show the ArtistAllSongs view when choosing to display all songs", () => {
29 | const enzymeWrapper = shallow( )
30 | // Simulate it was chosen to display all the songs in one view
31 | enzymeWrapper.find("#viewSelector").simulate("select", Artist.KEY_ALL_SONGS)
32 | // Look for the View displayed
33 | const view = enzymeWrapper.find("Connect(ArtistAllSongs)")
34 | expect( view ).toHaveLength(1)
35 | expect( view.prop("artistId") ).toEqual(props.artist.id)
36 | })
37 |
38 | it("should show the ArtistByAlbums view when choosing to display songs by album", () => {
39 | const enzymeWrapper = shallow( )
40 | // Simulate it was chosen to display songs by album in one view
41 | enzymeWrapper.find("#viewSelector").simulate("select", Artist.KEY_BY_ALBUM)
42 | // Look for the View displayed
43 | const view = enzymeWrapper.find("Connect(ArtistByAlbums)")
44 | expect( view ).toHaveLength(1)
45 | expect( view.prop("artistId") ).toEqual(props.artist.id)
46 | })
47 |
48 |
49 | })
--------------------------------------------------------------------------------
/src/components/Artist/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { loadOneArtist } from "../../redux/actions/artistsActions"
4 | // UI
5 | import Artist from './Artist'
6 |
7 | const mapStateToProps = (state, props) => {
8 | return {
9 | artist : state.artists.byId[props.artistId],
10 | }
11 | }
12 |
13 | const mapDispatchToProps = { loadOneArtist }
14 |
15 | export default connect(
16 | mapStateToProps,
17 | mapDispatchToProps
18 | )(Artist)
--------------------------------------------------------------------------------
/src/components/ArtistAllSongs/ArtistAllSongs.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import PropTypes from 'prop-types'
3 | // UI
4 | import SongsTableEnhanced from '../SongsTableEnhanced'
5 | import SongsTable from '../SongsTable/SongsTable'
6 |
7 | const COLUMNS_TO_SHOW = [SongsTable.columns.title, SongsTable.columns.album, SongsTable.columns.duration, SongsTable.columns.bitRate, SongsTable.columns.selectable, SongsTable.columns.download]
8 |
9 | export default function ArtistAllSongs(props) {
10 | const songs = props.songs
11 | return (
12 |
13 | )
14 | }
15 |
16 | ArtistAllSongs.propTypes = {
17 | style : PropTypes.object,
18 | songs : PropTypes.array,
19 | }
20 |
21 | ArtistAllSongs.defaultProps = {
22 | style : {},
23 | songs : [],
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/ArtistAllSongs/ArtistAllSongs.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import ArtistAllSongs from "./ArtistAllSongs"
4 |
5 | const props = {
6 | style : {
7 | flexGrow : 1
8 | },
9 | songs : [
10 | {
11 | "album": "21",
12 | "albumId": "506fe2e8-ee8d-4d12-b61d-4c0b96608f1c",
13 | "artist": "Adele",
14 | "artistId": "c299a083-4f82-4288-805f-0e97a9bb39a8",
15 | "bitRate": 320,
16 | "contentType": "audio/mpeg",
17 | "coverArt": "d5d99cde-4653-4182-bb80-5655019a6a58",
18 | "created": "2019-07-29T23:29:11",
19 | "discNumber": 1,
20 | "duration": 228,
21 | "genre": "Pop",
22 | "isDir": false,
23 | "isVideo": false,
24 | "parent": "f57575f2-08b4-450a-a1ce-9defda3c3234",
25 | "path": "Adele/21/01 - Rolling in the Deep.mp3",
26 | "size": 9849609,
27 | "starred": "2019-08-01T04:23:28",
28 | "suffix": "mp3",
29 | "title": "Rolling in the Deep",
30 | "track": 1,
31 | "type": "music",
32 | "year": 2011
33 | },
34 | {
35 | "album": "21",
36 | "albumId": "506fe2e8-ee8d-4d12-b61d-4c0b96608f1c",
37 | "artist": "Adele",
38 | "artistId": "c299a083-4f82-4288-805f-0e97a9bb39a8",
39 | "bitRate": 320,
40 | "contentType": "audio/mpeg",
41 | "coverArt": "8cc7604f-88d0-4664-857c-93516290424e",
42 | "created": "2019-07-29T23:28:59",
43 | "discNumber": 1,
44 | "duration": 285,
45 | "genre": "Pop",
46 | "isDir": false,
47 | "isVideo": false,
48 | "parent": "f57575f2-08b4-450a-a1ce-9defda3c3234",
49 | "path": "Adele/21/11 - Someone Like You.mp3",
50 | "size": 12121397,
51 | "starred": "2019-08-01T04:23:28",
52 | "suffix": "mp3",
53 | "title": "Someone Like You",
54 | "track": 11,
55 | "type": "music",
56 | "year": 2011
57 | }
58 | ]
59 | }
60 |
61 | describe("", () => {
62 |
63 | it("renders without crashing", () => {
64 | shallow( )
65 | })
66 |
67 | it("should show a table with the desired songs", () => {
68 | const enzymeWrapper = shallow( )
69 | expect( enzymeWrapper.find("Connect(SongsTableEnhanced)").prop("songs") ).toEqual(props.songs)
70 | })
71 |
72 | })
--------------------------------------------------------------------------------
/src/components/ArtistAllSongs/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { songsOfArtistSelector } from '../../redux/selectors/songSelectors'
4 | // UI
5 | import ArtistAllSongs from './ArtistAllSongs'
6 |
7 | const mapStateToProps = (state, props) => {
8 | return {
9 | songs: songsOfArtistSelector(state, props),
10 | }
11 | }
12 |
13 | export default connect(
14 | mapStateToProps,
15 | null
16 | )(ArtistAllSongs)
--------------------------------------------------------------------------------
/src/components/ArtistByAlbums/ArtistByAlbums.less:
--------------------------------------------------------------------------------
1 |
2 | .nav-artist-by-albums {
3 | flex : 0 0 230px;
4 | overflow: auto;
5 | }
6 |
7 | .nav-artist-by-albums .album-element {
8 | white-space: nowrap;
9 | overflow: hidden;
10 | text-overflow: ellipsis;
11 | }
12 |
13 | .artists-by-albums-container {
14 | overflow: auto;
15 | }
16 |
17 | @media (max-width: 767px) {
18 | .artists-by-albums-container {
19 | overflow: initial;
20 | }
21 | }
--------------------------------------------------------------------------------
/src/components/ArtistByAlbums/ArtistByAlbums.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import ArtistByAlbums from "./ArtistByAlbums"
4 |
5 | const props = {
6 | albums : [
7 | {
8 | artist: "Arctic Monkeys",
9 | artistId: "be9da6bf-32f1-459e-a00f-518e70c9fcbe",
10 | coverArt: "de18b3b9-9a3c-45f6-ac6b-a04f50dd0fc4",
11 | created: "2010-04-09T05:26:56",
12 | duration: 2456,
13 | id: "b96e45a4-b665-4e81-9138-454274cda106",
14 | name: "Whatever People Say I Am, That",
15 | song: ["song1", "song2"],
16 | songCount: 2,
17 | },
18 | {
19 | artist: "Arctic Monkeys",
20 | artistId: "be9da6bf-32f1-459e-a00f-518e70c9fcbe",
21 | coverArt: "8b7eb6ef-db1c-4240-bf09-fa57aeef16fa",
22 | created: "2010-04-09T05:32:06",
23 | duration: 2254,
24 | id: "bbbff36f-dfc9-401b-9b46-883c80d6ea82",
25 | name: "Favourite Worst Nightmare",
26 | song: ["song3"],
27 | songCount: 1,
28 | }
29 | ]
30 | }
31 |
32 | describe("", () => {
33 |
34 | it("renders without crashing", () => {
35 | shallow( )
36 | })
37 |
38 | it("should show a Select for small displays to pick an album", () => {
39 | const wrapper = shallow( )
40 | const albumsSelectData = props.albums.map(album => ({ value : album.id, label : album.name }))
41 | expect( wrapper.find("#selectAlbums").prop("data") ).toEqual(albumsSelectData)
42 | })
43 |
44 | it("should show NavItems for large displays to pick an album", () => {
45 | const wrapper = shallow( )
46 | // There should be a NavItem per album
47 | expect( wrapper.find(`[eventKey='${props.albums[0].id}']`) ).toHaveLength(1)
48 | expect( wrapper.find(`[eventKey='${props.albums[1].id}']`) ).toHaveLength(1)
49 | })
50 |
51 | it("should show the correct album when selected an album on small displays", () => {
52 | const wrapper = shallow( )
53 | // Simulate an album was selected
54 | const albumId = props.albums[0].id
55 | wrapper.find("withLocale(defaultProps(SelectPicker))").simulate("change", albumId)
56 | // Look for the Album displayed
57 | expect( wrapper.find("#album").prop("albumId") ).toEqual(albumId)
58 | })
59 |
60 | it("should show the correct album when selected an album on large displays", () => {
61 | const wrapper = shallow( )
62 | // Simulate an album was selected
63 | const albumId = props.albums[0].id
64 | wrapper.find(".nav-artist-by-albums").simulate("select", albumId)
65 | // Look for the Album displayed
66 | expect( wrapper.find("#album").prop("albumId") ).toEqual(albumId)
67 | })
68 |
69 | })
--------------------------------------------------------------------------------
/src/components/ArtistByAlbums/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { getAlbumsOfArtist } from "../../redux/selectors/albumSelectors"
4 | // UI
5 | import ArtistByAlbums from './ArtistByAlbums'
6 |
7 | const mapStateToProps = (state, props) => {
8 | return {
9 | albums : getAlbumsOfArtist(state, props),
10 | }
11 | }
12 |
13 | export default connect(
14 | mapStateToProps,
15 | null
16 | )(ArtistByAlbums)
--------------------------------------------------------------------------------
/src/components/ArtistListElement/ArtistListElement.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import PropTypes from 'prop-types'
3 | import { navigate } from "@reach/router"
4 | // UI
5 | import "./ArtistListElement.less"
6 | import { Icon, Col } from 'rsuite'
7 |
8 | export default class ArtistListElement extends React.Component {
9 |
10 | shouldComponentUpdate(nextProps, nextState) {
11 | let shouldReRender = true
12 | // Avoid re-rendering if the current artist playing didnt change
13 | if( nextProps.currentSongPlaying !== null &&
14 | nextProps.currentSongPlaying.id !== this.props.currentSongPlaying.id ) {
15 | // Check if this artists needs to be re-rendered
16 | const nextArtistId = nextProps.currentSongPlaying.artistId
17 | const currentArtistId = this.props.currentSongPlaying.artistId
18 | shouldReRender = false
19 | // Check if this artist used to have it and now it doesnt
20 | if( this.props.artist.id === currentArtistId && this.props.artist.id !== nextArtistId ) {
21 | shouldReRender = true
22 | }
23 | // Check if this artist used not to be here and now it does
24 | if( this.props.artist.id !== currentArtistId && this.props.artist.id === nextArtistId ) {
25 | shouldReRender = true
26 | }
27 | }
28 | return shouldReRender
29 | }
30 |
31 | render() {
32 | const currentArtistPlayingId = this.props.currentSongPlaying && this.props.currentSongPlaying.artistId
33 | const artist = this.props.artist
34 | return (
35 | {navigate("/artists/"+artist.id)} }>
36 | {artist.name}
37 |
38 | )
39 | }
40 | }
41 |
42 | ArtistListElement.propTypes = {
43 | currentSongPlaying : PropTypes.object,
44 | artist : PropTypes.object,
45 | }
46 |
47 | ArtistListElement.defaultProps = {
48 | currentSongPlaying : {},
49 | artist : {}
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/ArtistListElement/ArtistListElement.less:
--------------------------------------------------------------------------------
1 | .link_to_artist {
2 | padding : 15px 20px;
3 | transition: color .3s linear, background-color .3s linear;
4 | transition-property: color, background-color;
5 | transition-duration: 0.3s, 0.3s;
6 | transition-timing-function: linear, linear;
7 | transition-delay: 0s, 0s;
8 | }
9 |
10 | .link_to_artist i {
11 | display: none;
12 | }
13 |
14 | .link_to_artist.currently-playing i {
15 | display: initial;
16 | }
--------------------------------------------------------------------------------
/src/components/ArtistListElement/ArtistListElement.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import ArtistListElement from "./ArtistListElement"
4 |
5 | const currentSongPlaying = {
6 | artistId : "myid"
7 | }
8 |
9 | const artist = {
10 | id : "myid",
11 | name : "my name"
12 | }
13 |
14 | describe("", () => {
15 |
16 | it("renders without crashing", () => {
17 | shallow( )
18 | })
19 |
20 | it("should render the specified artist correctly", () => {
21 | const wrapper = shallow( )
22 | const artists = wrapper.find("#name")
23 | expect(wrapper.find("#name").html()).toContain( "my name" )
24 | })
25 |
26 | it("should contain an indicator of the artist currently playing", () => {
27 | const wrapper = shallow( )
28 | expect(wrapper.find("#name.currently-playing").html()).toContain( "my name" )
29 | // Change the currently playing artist and expect the class "playing" to not be present
30 | wrapper.setProps({currentSongPlaying : {artistId : "2"} })
31 | expect(wrapper.find("#name.currently-playing").exists()).toBeFalsy()
32 | })
33 |
34 | })
--------------------------------------------------------------------------------
/src/components/ArtistListElement/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { getSongCurrentlyPlayingSelector } from '../../redux/selectors/musicPlayerSelector'
4 | // UI
5 | import ArtistListElement from './ArtistListElement'
6 |
7 | const mapStateToProps = (state, ownProps) => {
8 | return {
9 | currentSongPlaying : getSongCurrentlyPlayingSelector(state),
10 | }
11 | }
12 |
13 | export default connect(
14 | mapStateToProps,
15 | null
16 | )(ArtistListElement)
--------------------------------------------------------------------------------
/src/components/ArtistListHeader/ArtistListHeader.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import PropTypes from 'prop-types'
3 | // UI
4 | import { Col } from 'rsuite'
5 |
6 | function ArtistListHeader(props) {
7 | const { name } = props
8 | return (
9 |
10 | {name}
11 |
12 | )
13 | }
14 |
15 | ArtistListHeader.propTypes = {
16 | name : PropTypes.string
17 | }
18 |
19 | export default ArtistListHeader
--------------------------------------------------------------------------------
/src/components/ArtistListHeader/ArtistListHeader.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import ArtistListHeader from "./ArtistListHeader"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing", () => {
8 | shallow( )
9 | })
10 |
11 | it("should load a title with the header", () => {
12 | const wrapper = shallow( )
13 | expect( wrapper.find("#title").text() ).toEqual("my test")
14 | })
15 |
16 | })
--------------------------------------------------------------------------------
/src/components/ArtistListHeader/index.js:
--------------------------------------------------------------------------------
1 | // UI
2 | import ArtistListHeader from './ArtistListHeader'
3 |
4 | export default ArtistListHeader
--------------------------------------------------------------------------------
/src/components/ArtistListLoader/ArtistListLoader.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | // UI
3 | import { Col } from 'rsuite'
4 |
5 | function ArtistListLoader(props) {
6 | return (
7 | Loading...
8 | )
9 | }
10 |
11 | export default ArtistListLoader
--------------------------------------------------------------------------------
/src/components/ArtistListLoader/ArtistListLoader.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import ArtistListLoader from "./ArtistListLoader"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing", () => {
8 | shallow( )
9 | })
10 |
11 | })
--------------------------------------------------------------------------------
/src/components/ArtistListLoader/index.js:
--------------------------------------------------------------------------------
1 | // UI
2 | import ArtistListLoader from './ArtistListLoader'
3 |
4 | export default ArtistListLoader
--------------------------------------------------------------------------------
/src/components/ArtistsList/ArtistsList.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import { ArtistsList, getNextArtists } from "./ArtistsList"
4 | import { getArtistsWithHeaders } from "../../redux/selectors/artistSelectors"
5 |
6 | // Mock a list of mock artists
7 | const props = {
8 | artists : getArtistsWithHeaders(
9 | {
10 | artists : {
11 | byIndex :
12 | ['A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z']
13 | .map(i => {
14 | return {
15 | name:i,
16 | artist : [1,2,3,4,5].map(n => {
17 | return { id : i+n, name : n }
18 | })
19 | }
20 | })
21 | }
22 | }
23 | )
24 | }
25 |
26 | describe("", () => {
27 |
28 | it("renders without crashing", () => {
29 | shallow( )
30 | })
31 |
32 | it("should load the artists if there are not loaded", () => {
33 | const loadArtists = jest.fn()
34 | shallow( )
35 | expect( loadArtists ).toHaveBeenCalled()
36 | })
37 |
38 | it("should take the artists from cache if they are already loaded", () => {
39 | const loadArtists = jest.fn()
40 | shallow( )
41 | expect( loadArtists ).toHaveBeenCalledTimes(0)
42 | })
43 |
44 | it("should contain an infinite scroll to load all the artists", () => {
45 | const enzymeWrapper = shallow( )
46 | expect( enzymeWrapper.find("InfiniteScroll")).toHaveLength(1)
47 | })
48 |
49 | it("should be able to paginate artists", () => {
50 | const pageSize = 5
51 | const firstPage = getNextArtists(0, props.artists, pageSize)
52 | expect( firstPage.length ).toEqual(7)
53 | const secondPage = getNextArtists(firstPage.length, props.artists, pageSize)
54 | expect( firstPage.length ).toEqual(7)
55 | })
56 |
57 | })
--------------------------------------------------------------------------------
/src/components/ArtistsList/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { loadArtists } from "../../redux/actions/artistsActions"
4 | import { getArtistsWithHeaders } from "../../redux/selectors/artistSelectors"
5 | // UI
6 | import {ArtistsList} from './ArtistsList'
7 |
8 | const mapStateToProps = (state) => {
9 | return {
10 | artists: getArtistsWithHeaders(state),
11 | }
12 | }
13 |
14 | const mapDispatchToProps = { loadArtists }
15 |
16 | export default connect(
17 | mapStateToProps,
18 | mapDispatchToProps
19 | )(ArtistsList)
--------------------------------------------------------------------------------
/src/components/AuthenticatedComponent/AuthenticatedComponent.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import PropTypes from 'prop-types'
3 | import { Redirect } from "@reach/router"
4 | // UI
5 | import { Loader } from 'rsuite';
6 |
7 | export default class AuthenticatedComponent extends React.Component {
8 |
9 | componentDidMount() {
10 | // Check if we need to lazy login
11 | if( !this.props.isAuthenticated ) {
12 | this.props.lazyLoginUser && this.props.lazyLoginUser()
13 | }
14 | }
15 |
16 | render() {
17 | const authenticatingView = (
)
18 | return (
19 |
20 | {
21 | this.props.isAuthenticating === true
22 | ? authenticatingView : (
23 | this.props.isAuthenticated === true
24 | ? this.props.children
25 | :
26 | )
27 | }
28 |
29 | )
30 |
31 | }
32 | }
33 |
34 | AuthenticatedComponent.propTypes = {
35 | isAuthenticated : PropTypes.bool.isRequired,
36 | isAuthenticating : PropTypes.bool.isRequired,
37 | lazyLoginUser : PropTypes.func
38 | }
39 |
40 | AuthenticatedComponent.defaultProps = {
41 | isAuthenticated : false,
42 | isAuthenticating : false,
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/AuthenticatedComponent/AuthenticatedComponent.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import AuthenticatedComponent from "./AuthenticatedComponent"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing", () => {
8 | shallow( )
9 | })
10 |
11 | it("should try to lazy log in when the user is not authenticated", () => {
12 | const lazyLoginUser = jest.fn()
13 | const enzymeWrapper = shallow( )
14 | expect( lazyLoginUser ).toHaveBeenCalled()
15 | })
16 |
17 | it("should not render anything when authenticating", () => {
18 | const enzymeWrapper = shallow( )
19 | expect( enzymeWrapper.find("#loading") ).toHaveLength(1)
20 | })
21 |
22 | it("should redirect to log in when not authenticated", () => {
23 | const enzymeWrapper = shallow( )
24 | expect( enzymeWrapper.find("Redirect") ).toHaveLength(1)
25 | })
26 |
27 | it("should render children when authenticated", () => {
28 | const enzymeWrapper = shallow(
29 |
30 |
31 |
32 | )
33 | expect( enzymeWrapper.find("#child") ).toHaveLength(1)
34 | })
35 |
36 | })
--------------------------------------------------------------------------------
/src/components/AuthenticatedComponent/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { lazyLoginUser } from "../../redux/actions/authActions"
4 | // UI
5 | import AuthenticatedComponent from './AuthenticatedComponent'
6 |
7 | const mapStateToProps = (state) => ({
8 | isAuthenticated: state.auth.isAuthenticated,
9 | isAuthenticating: state.auth.isAuthenticating
10 | })
11 |
12 | const mapDispatchToProps = { lazyLoginUser }
13 |
14 | export default connect(
15 | mapStateToProps,
16 | mapDispatchToProps
17 | )(AuthenticatedComponent)
--------------------------------------------------------------------------------
/src/components/CreatePlaylistModal/CreatePlaylistModal.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | // UI
4 | import { Button, Modal, Form, FormGroup, FormControl, ControlLabel } from 'rsuite'
5 |
6 | export default class CreatePlaylistModal extends React.Component {
7 |
8 | constructor(props) {
9 | super(props)
10 | this.state = { playlistNameErrorMessage : null }
11 | this.newPlaylist = {name : ""}
12 | }
13 |
14 | closeModal = () => {
15 | this.props.onClosePlaylistModal && this.props.onClosePlaylistModal()
16 | }
17 |
18 | onPlaylistFormChange = (value) => {
19 | this.newPlaylist = value
20 | }
21 |
22 | closeModalAndCreate = () => {
23 | if( this.newPlaylist.name.length > 0 ) {
24 | this.setState({playlistNameErrorMessage : null})
25 | this.props.createPlaylist( this.newPlaylist.name )
26 | this.closeModal()
27 | }
28 | else {
29 | this.setState({playlistNameErrorMessage : "Name required"})
30 | }
31 | }
32 |
33 | handleKeyDown = (e) => {
34 | if( e.key === "Enter" ) {
35 | this.closeModalAndCreate()
36 | }
37 | }
38 |
39 | render() {
40 | return (
41 |
42 |
43 | New Playlist
44 |
45 |
46 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | )
59 | }
60 | }
61 |
62 | CreatePlaylistModal.propTypes = {
63 | showModal : PropTypes.bool.isRequired,
64 | onClosePlaylistModal : PropTypes.func,
65 | createPlaylist : PropTypes.func.isRequired,
66 | }
67 |
68 | CreatePlaylistModal.defaultProps = {
69 | showModal : false,
70 | createPlaylist : () => (null),
71 | }
72 |
--------------------------------------------------------------------------------
/src/components/CreatePlaylistModal/CreatePlaylistModal.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import CreatePlaylistModal from "./CreatePlaylistModal"
4 |
5 |
6 | describe("", () => {
7 |
8 | it("renders without crashing", () => {
9 | shallow( )
10 | })
11 |
12 | it("should not show the modal when not wanted", () => {
13 | const wrapper = shallow( )
14 | expect(wrapper.find("#modal").prop("show")).toBe(false)
15 | })
16 |
17 | it("should show the modal when wanted", () => {
18 | const wrapper = shallow( )
19 | expect(wrapper.find("#modal").prop("show")).toBe(true)
20 | })
21 |
22 | it("should show error messages when playlist form is invalid", () => {
23 | // Mock playlist creation function
24 | const createPlaylist = jest.fn()
25 | // Mount
26 | const wrapper = shallow( )
27 | wrapper.find("#create_playlist").simulate("click")
28 | // Test form is not submitted and error messages are shown
29 | expect( createPlaylist ).toHaveBeenCalledTimes(0)
30 | expect(typeof wrapper.find("[name='name']").prop("errorMessage")).toBe("string")
31 | })
32 |
33 | it("should trigger playlist creation when form is valid", () => {
34 | // Mock playlist creation and modal reaction function
35 | const createPlaylist = jest.fn()
36 | const onClosePlaylistModal = jest.fn()
37 | // Mount
38 | const wrapper = shallow( )
39 | wrapper.find("Form").simulate("change", {
40 | name : "name"
41 | })
42 | wrapper.find("#create_playlist").simulate("click")
43 | // Test form is submitted and modal is hidden
44 | expect( createPlaylist ).toHaveBeenCalledTimes(1)
45 | expect( onClosePlaylistModal ).toHaveBeenCalledTimes(1)
46 | })
47 |
48 | it("should close the modal on close button clicked", () => {
49 | const createPlaylist = jest.fn()
50 | const onClosePlaylistModal = jest.fn()
51 | const wrapper = shallow( )
52 | wrapper.find("#close").simulate("click")
53 | expect( onClosePlaylistModal ).toHaveBeenCalledTimes(1)
54 | expect( createPlaylist ).toHaveBeenCalledTimes(0)
55 | })
56 |
57 | })
--------------------------------------------------------------------------------
/src/components/CreatePlaylistModal/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { createPlaylist } from "../../redux/actions/playlistsActions"
4 | // UI
5 | import CreatePlaylistModal from './CreatePlaylistModal'
6 |
7 | const mapDispatchToProps = { createPlaylist }
8 |
9 | export default connect(
10 | null,
11 | mapDispatchToProps
12 | )(CreatePlaylistModal)
--------------------------------------------------------------------------------
/src/components/DeletePlaylistModal/DeletePlaylistModal.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { navigate } from "@reach/router"
3 | import PropTypes from 'prop-types'
4 | // UI
5 | import { Button, Modal, Icon, Input } from 'rsuite'
6 |
7 | export default class DeletePlaylistModal extends React.Component {
8 |
9 | constructor(props) {
10 | super(props)
11 | this.waitingForDeletion = false
12 | this.state = {deleteNameError : false}
13 | this.confirmation_name = ""
14 | }
15 |
16 | componentDidUpdate(prevProps) {
17 | // Check if the playlist was deleted: it doesnt exist and we dispatched a deletion request
18 | // Alternative: Navigate on deletion without waiting for confirmation
19 | if(!this.props.playlist && this.waitingForDeletion ) {
20 | navigate("/")
21 | }
22 | }
23 |
24 | closeModalAndDelete = () => {
25 | // validate playlist name
26 | if( this.confirmation_name === this.props.playlist.name ) {
27 | this.waitingForDeletion = true
28 | this.props.deletePlaylist(this.props.playlist)
29 | this.closeDeleteModal()
30 | }
31 | else {
32 | this.setState({deleteNameError : true})
33 | }
34 | }
35 |
36 | closeDeleteModal = () => {
37 | this.props.onHide && this.props.onHide()
38 | }
39 |
40 | render() {
41 | const playlistToDelete = this.props.playlist || {}
42 | return (
43 |
44 |
45 |
46 | {' '}
47 | Once a playlist is deleted, it can't be recovered. If you want to proceed, write the name of the playlist "{playlistToDelete.name}":
48 | {this.confirmation_name = value})} style={{width:"100%", marginTop:"10px"}} />
49 | {this.state.deleteNameError ? Name does not match : null}
50 |
51 |
52 |
53 |
54 |
55 |
56 | )
57 | }
58 | }
59 |
60 | DeletePlaylistModal.propTypes = {
61 | onHide: PropTypes.func,
62 | deletePlaylist: PropTypes.func.isRequired,
63 | playlistId : PropTypes.string.isRequired
64 | }
65 |
--------------------------------------------------------------------------------
/src/components/DeletePlaylistModal/DeletePlaylistModal.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import DeletePlaylistModal from "./DeletePlaylistModal"
4 |
5 | describe("", () => {
6 |
7 | const playlist = {
8 | "id" : "my_id",
9 | "name" : "my playlist",
10 | "owner" : "the owner",
11 | "songCount" : 24,
12 | "duration" : 729,
13 | "comment" : "this is a comment"
14 | }
15 |
16 | it("renders without crashing", () => {
17 | shallow( null}/> )
18 | })
19 |
20 | it("should let me delete a playlist", () => {
21 | const deletePlaylist = jest.fn()
22 | const wrapper = shallow( )
23 | // Try to delete the playlist
24 | wrapper.find("#confirm_name").simulate("change", "my playlist")
25 | wrapper.find("#deleteButton").simulate("click")
26 | // Playlist should be deleted
27 | expect(wrapper.find("#errorMessage").exists()).toBeFalsy()
28 | expect(deletePlaylist).toHaveBeenCalledTimes(1)
29 | })
30 |
31 | it("should not let me delete a playlist without confirmation", () => {
32 | const deletePlaylist = jest.fn()
33 | const wrapper = shallow( )
34 | // Try to delete the playlist without confirmation
35 | wrapper.find("#deleteButton").simulate("click")
36 | // Playlist should not be deleted and an error message should be displayed
37 | expect(wrapper.find("#errorMessage").exists()).toBeTruthy()
38 | expect(deletePlaylist).toHaveBeenCalledTimes(0)
39 | })
40 |
41 | it("should let me cancel the deletion", () => {
42 | const deletePlaylist = jest.fn()
43 | const wrapper = shallow( )
44 | // Try to cancel the operation
45 | wrapper.find("#cancelButton").simulate("click")
46 | // The deletion should not be triggered
47 | expect(deletePlaylist).toHaveBeenCalledTimes(0)
48 | })
49 |
50 | })
--------------------------------------------------------------------------------
/src/components/DeletePlaylistModal/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { deletePlaylist } from "../../redux/actions/playlistsActions"
4 | // UI
5 | import DeletePlaylistModal from './DeletePlaylistModal'
6 |
7 | const mapStateToProps = (state, ownProps) => {
8 | return {
9 | "playlist" : state.playlists.byId[ownProps.playlistId],
10 | }
11 | }
12 |
13 | const mapDispatchToProps = { deletePlaylist }
14 |
15 | export default connect(
16 | mapStateToProps,
17 | mapDispatchToProps
18 | )(DeletePlaylistModal)
--------------------------------------------------------------------------------
/src/components/EditPlaylistModal/EditPlaylistModal.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import EditPlaylistModal from "./EditPlaylistModal"
4 |
5 | describe("", () => {
6 |
7 | const playlist = {
8 | "id" : "my_id",
9 | "name" : "my playlist",
10 | "comment" : "this is a comment",
11 | "isPublic" : true
12 | }
13 |
14 | it("renders without crashing", () => {
15 | shallow( null}/> )
16 | })
17 |
18 | it("should let me edit a playlist", () => {
19 | const editPlaylist = jest.fn()
20 | const wrapper = shallow( )
21 | // Try to edit the playlist
22 | wrapper.find("#name").simulate("change", "new name")
23 | wrapper.find("#comment").simulate("change", "new comment")
24 | wrapper.find("#isPublic").simulate("change", null, false)
25 | wrapper.find("#editButton").simulate("click")
26 | // Playlist should be edited
27 | expect(editPlaylist).toHaveBeenCalledWith("my_id", "new name", "new comment", false)
28 | })
29 |
30 | it("should not let me edit a playlist without a name", () => {
31 | const editPlaylist = jest.fn()
32 | const wrapper = shallow( )
33 | // Try to edit the playlist
34 | wrapper.find("#name").simulate("change", "")
35 | wrapper.find("#editButton").simulate("click")
36 | // Playlist should not be edited and an error message should be displayed
37 | expect(wrapper.find("#nameErrorMessage").exists()).toBeTruthy()
38 | expect(editPlaylist).toHaveBeenCalledTimes(0)
39 | })
40 |
41 |
42 | it("should let me cancel the edition", () => {
43 | const editPlaylist = jest.fn()
44 | const wrapper = shallow( )
45 | // Try to cancel the operation
46 | wrapper.find("#cancelButton").simulate("click")
47 | // The edition should not be triggered
48 | expect(editPlaylist).toHaveBeenCalledTimes(0)
49 | })
50 |
51 | })
--------------------------------------------------------------------------------
/src/components/EditPlaylistModal/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { editPlaylist } from "../../redux/actions/playlistsActions"
4 | // UI
5 | import EditPlaylistModal from './EditPlaylistModal'
6 |
7 | const mapStateToProps = (state, ownProps) => {
8 | return {
9 | "playlist" : state.playlists.byId[ownProps.playlistId],
10 | }
11 | }
12 |
13 | const mapDispatchToProps = { editPlaylist }
14 |
15 | export default connect(
16 | mapStateToProps,
17 | mapDispatchToProps
18 | )(EditPlaylistModal)
--------------------------------------------------------------------------------
/src/components/FavouritesView/FavouritesView.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import PropTypes from 'prop-types'
3 | // Utils
4 | import { seconds_to_hhmmss } from "../../utils/formatting.js"
5 | // UI
6 | import SongsTableEnhanced from '../SongsTableEnhanced'
7 | import { Button } from 'rsuite'
8 | import SongsTable from '../SongsTable/SongsTable'
9 |
10 | const COLUMNS_TO_SHOW = [SongsTable.columns.selectable, SongsTable.columns.title, SongsTable.columns.artist, SongsTable.columns.album, SongsTable.columns.duration, SongsTable.columns.bitRate, SongsTable.columns.download, SongsTable.columns.starred]
11 |
12 | export default class FavouritesView extends React.Component {
13 |
14 | constructor(props) {
15 | super(props)
16 | this.state = { selectedSongs : [], duration : 0 }
17 | }
18 |
19 | componentDidMount() {
20 | this.props.loadFavouriteSongs()
21 | }
22 |
23 | componentDidUpdate(prevProps) {
24 | if( prevProps.songs.length !== this.props.songs.length ) {
25 | this.setState({selectedSongs: [], duration : this.props.songs.reduce( (a,b) => ({duration: a.duration+b.duration}), {duration:0} ).duration})
26 | }
27 | }
28 |
29 | /* Listeners */
30 |
31 | onSongsSelected = (selectedSongs) => {
32 | this.setState({selectedSongs: selectedSongs})
33 | }
34 |
35 | removeSelectedSongs = () => {
36 | if( this.state.selectedSongs.length > 0 ) {
37 | this.props.setStarOnSongs(this.state.selectedSongs, false)
38 | }
39 | }
40 |
41 | render() {
42 | const songs = this.props.songs
43 | const duration = this.state.duration
44 | const disableButton = this.state.selectedSongs && this.state.selectedSongs.length === 0
45 | return (
46 |
47 |
48 |
49 |
Favourites
50 | { songs.length } songs, {seconds_to_hhmmss(duration)}
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | )
59 | }
60 | }
61 |
62 | FavouritesView.propTypes = {
63 | loadFavouriteSongs : PropTypes.func.isRequired,
64 | setStarOnSongs : PropTypes.func,
65 | songs : PropTypes.array,
66 |
67 | }
68 |
69 | FavouritesView.defaultProps = {
70 | songs : []
71 | }
72 |
--------------------------------------------------------------------------------
/src/components/FavouritesView/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { loadFavouriteSongs, setStarOnSongs } from "../../redux/actions/favouritesActions"
4 | import { favouriteSongsSelector } from '../../redux/selectors/songSelectors'
5 | // UI
6 | import FavouritesView from './FavouritesView'
7 |
8 | const mapStateToProps = (state, ownProps) => {
9 | return {
10 | "songs" : favouriteSongsSelector(state)
11 | }
12 | }
13 |
14 | const mapDispatchToProps = { loadFavouriteSongs, setStarOnSongs }
15 |
16 | export default connect(
17 | mapStateToProps,
18 | mapDispatchToProps
19 | )(FavouritesView)
--------------------------------------------------------------------------------
/src/components/GenreSongs/GenreSongs.js:
--------------------------------------------------------------------------------
1 | import React, {useEffect} from 'react'
2 | import PropTypes from 'prop-types'
3 | // UI
4 | import SongsTable from '../SongsTable/SongsTable'
5 | import SongsTableEnhanced from '../SongsTableEnhanced'
6 |
7 | const COLUMNS_TO_SHOW = [SongsTable.columns.title, SongsTable.columns.artist, SongsTable.columns.album, SongsTable.columns.duration, SongsTable.columns.bitRate, SongsTable.columns.download, SongsTable.columns.selectable]
8 |
9 | export default function GenreSongs(props) {
10 | const { genre, loadSongsOfGenre, songs, ...rest } = props
11 |
12 | useEffect(()=> {
13 | if( genre !== null) {
14 | loadSongsOfGenre(genre)
15 | }
16 | }, [genre, loadSongsOfGenre])
17 |
18 | return (
19 |
20 | )
21 | }
22 |
23 | GenreSongs.propTypes = {
24 | genre: PropTypes.object,
25 | loadSongsOfGenre: PropTypes.func,
26 | songs : PropTypes.array,
27 | }
28 |
29 | GenreSongs.defaultProps = {
30 | genre: null,
31 | loadSongsOfGenre: () => null,
32 | songs: []
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/GenreSongs/GenreSongs.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import GenreSongs from "./GenreSongs"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing", () => {
8 | shallow( )
9 | })
10 |
11 | it("should load songs when a genre is selected", () => {
12 | const loadSongsOfGenre = jest.fn()
13 | const wrapper = shallow( )
14 | const genre = {value:"blues"}
15 | // change the genre
16 | wrapper.setProps({genre})
17 | expect(loadSongsOfGenre).toHaveBeenCalledWith(genre)
18 | })
19 |
20 | })
--------------------------------------------------------------------------------
/src/components/GenreSongs/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { loadSongsOfGenre } from "../../redux/actions/genresActions"
4 | import { songsOfGenreSelector } from '../../redux/selectors/songSelectors'
5 | // UI
6 | import GenreSongs from './GenreSongs'
7 |
8 | const mapStateToProps = (state, ownProps) => {
9 | return {
10 | "songs" : songsOfGenreSelector(state, ownProps)
11 | }
12 | }
13 |
14 | const mapDispatchToProps = { loadSongsOfGenre }
15 |
16 | export default connect(
17 | mapStateToProps,
18 | mapDispatchToProps
19 | )(GenreSongs)
--------------------------------------------------------------------------------
/src/components/GenresPicker/GenresPicker.js:
--------------------------------------------------------------------------------
1 | import React, {useState, useEffect} from "react"
2 | import PropTypes from 'prop-types'
3 | // utils
4 | import subsonic from "../../api/subsonicApi"
5 | import {SelectPicker } from 'rsuite'
6 |
7 | export default function GenresPicker(props) {
8 | const {beginAsyncTask, asyncTaskSuccess, asyncTaskError, onGenreChanged, displaySongCount, ...rest} = props
9 | const [genres, setGenres] = useState([])
10 |
11 | // Get the available genres
12 | useEffect(() => {
13 | const getGenres = async () => {
14 | // Get the available genres
15 | beginAsyncTask()
16 | try {
17 | const result = await subsonic.getGenres()
18 | asyncTaskSuccess()
19 | setGenres( result.map(g => ({label:`${g.value} (${displaySongCount ? g.songCount : g.albumCount})`, value:g})) )
20 | }
21 | catch(err) {
22 | console.log(err)
23 | asyncTaskError("Unable to get genres")
24 | }
25 | }
26 | getGenres()
27 | }, [beginAsyncTask, asyncTaskSuccess, asyncTaskError, displaySongCount, setGenres])
28 |
29 | // Notify when the value has changed
30 | const onValueChanged = (genre) => onGenreChanged(genre)
31 |
32 | return (
33 |
34 | )
35 | }
36 |
37 | GenresPicker.propTypes = {
38 | beginAsyncTask: PropTypes.func,
39 | asyncTaskSuccess: PropTypes.func,
40 | asyncTaskError: PropTypes.func,
41 | onGenreChanged: PropTypes.func,
42 | displaySongCount: PropTypes.bool
43 | }
44 |
45 | GenresPicker.defaultProps = {
46 | beginAsyncTask: ()=> null,
47 | asyncTaskSuccess: ()=> null,
48 | asyncTaskError: ()=> null,
49 | onGenreChanged: ()=> null,
50 | displaySongCount: false,
51 | }
52 |
--------------------------------------------------------------------------------
/src/components/GenresPicker/GenresPicker.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import GenresPicker from "./GenresPicker"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing", () => {
8 | shallow( )
9 | })
10 |
11 | it("notifies when a genre changes", () => {
12 | const onGenreChanged = jest.fn()
13 | const wrapper = shallow( )
14 | // change the genre
15 | wrapper.find("#genrePicker").simulate("change", "genre")
16 | expect(onGenreChanged).toHaveBeenCalledWith("genre")
17 | })
18 |
19 | })
--------------------------------------------------------------------------------
/src/components/GenresPicker/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import {beginAsyncTask, asyncTaskSuccess, asyncTaskError } from "../../redux/actions/apiStatusActions"
4 | // UI
5 | import GenresPicker from './GenresPicker'
6 |
7 | const mapDispatchToProps = { beginAsyncTask, asyncTaskSuccess, asyncTaskError }
8 |
9 | export default connect(
10 | null,
11 | mapDispatchToProps
12 | )(GenresPicker)
--------------------------------------------------------------------------------
/src/components/GenresView/GenresView.js:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react'
2 | // UI
3 | import ResponsiveTitle from '../ResponsiveTitle'
4 | import GenreSongs from '../GenreSongs/'
5 | import GenresPicker from "../GenresPicker"
6 | import { Col } from 'rsuite'
7 |
8 | export default function GenresView(props) {
9 | const [genre, setGenre] = useState({})
10 |
11 | return (
12 |
13 |
14 |
15 | Genres
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/GenresView/GenresView.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import GenresView from "./GenresView"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing", () => {
8 | shallow( )
9 | })
10 |
11 | })
--------------------------------------------------------------------------------
/src/components/GenresView/index.js:
--------------------------------------------------------------------------------
1 | import GenresView from './GenresView'
2 |
3 | export default GenresView
--------------------------------------------------------------------------------
/src/components/InfiniteLineLoader/InfiniteLineLoader.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import "./InfiniteLineLoader.less"
4 |
5 | function InfiniteLineLoader(props) {
6 | const display = props.isLoading ? "initial" : "none"
7 | return (
8 |
9 | )
10 | }
11 |
12 | InfiniteLineLoader.propTypes = {
13 | isLoading : PropTypes.bool
14 | }
15 |
16 | InfiniteLineLoader.defaultProps = {
17 | isLoading : false
18 | }
19 |
20 | export default InfiniteLineLoader
--------------------------------------------------------------------------------
/src/components/InfiniteLineLoader/InfiniteLineLoader.less:
--------------------------------------------------------------------------------
1 | .loader {
2 | height: 4px;
3 | width: 100%;
4 | position: relative;
5 | overflow: hidden;
6 | }
7 |
8 | .loader:before{
9 | display: block;
10 | position: absolute;
11 | content: "";
12 | left: -200px;
13 | width: 200px;
14 | height: 4px;
15 | animation: loading 2s linear infinite;
16 | }
17 |
18 | @keyframes loading {
19 | from {left: -200px; width: 30%;}
20 | 50% {width: 30%;}
21 | 70% {width: 70%;}
22 | 80% { left: 50%;}
23 | 95% {left: 120%;}
24 | to {left: 100%;}
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/InfiniteLineLoader/InfiniteLineLoader.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import InfiniteLineLoader from "./InfiniteLineLoader"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing", () => {
8 | shallow( )
9 | })
10 |
11 | it("should show the loader on demand", () => {
12 | const wrapper = shallow( )
13 | const style = wrapper.find(".loader").prop("style")
14 | expect(style.display).toBe("initial")
15 | })
16 |
17 | it("should hide the loader on demand", () => {
18 | const wrapper = shallow( )
19 | const style = wrapper.find(".loader").prop("style")
20 | expect(style.display).toBe("none")
21 | })
22 |
23 | })
--------------------------------------------------------------------------------
/src/components/InfiniteLineLoader/index.js:
--------------------------------------------------------------------------------
1 | import InfiniteLineLoader from "./InfiniteLineLoader"
2 |
3 | export default InfiniteLineLoader
--------------------------------------------------------------------------------
/src/components/LoginView/LoginView.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import LoginView from "./LoginView"
4 | import { navigate } from "@reach/router"
5 |
6 | jest.mock('@reach/router', () => ({
7 | navigate: jest.fn(),
8 | }))
9 |
10 | const mockedSubmitEvent = {
11 | target: {},
12 | stopPropagation : () => null,
13 | preventDefault : () => null,
14 | }
15 |
16 | describe("", () => {
17 |
18 | it("renders without crashing", () => {
19 | shallow( )
20 | })
21 |
22 | it("should try to lazy log in when the user is not authenticated", () => {
23 | const lazyLoginUser = jest.fn()
24 | const enzymeWrapper = shallow( )
25 | expect( lazyLoginUser ).toHaveBeenCalled()
26 | })
27 |
28 | it("should redirect to home when the user is authenticated", () => {
29 | const enzymeWrapper = shallow( )
30 | expect( navigate ).toHaveBeenCalled()
31 | })
32 |
33 | it("should show error messages when login form is invalid", () => {
34 | // Mock functions and submit event
35 | const loginUser = jest.fn()
36 | // Mount
37 | const enzymeWrapper = shallow( )
38 | enzymeWrapper.find("Form").simulate("submit", mockedSubmitEvent)
39 | // Test form is not submitted and error messages are shown
40 | expect( loginUser ).toHaveBeenCalledTimes(0)
41 | expect(typeof enzymeWrapper.find("[name='host']").prop("errorMessage")).toBe("string")
42 | expect(typeof enzymeWrapper.find("[name='username']").prop("errorMessage")).toBe("string")
43 | expect(typeof enzymeWrapper.find("[name='password']").prop("errorMessage")).toBe("string")
44 | })
45 |
46 | it("should trigger login when form is valid", () => {
47 | // Mock functions and submit event
48 | const loginUser = jest.fn()
49 | // Test form is submitted when setting values to fields
50 | const enzymeWrapper = shallow( )
51 | enzymeWrapper.find("Form").simulate("change", {
52 | host : "host",
53 | username : "username",
54 | password : "password"
55 | })
56 | enzymeWrapper.find("Form").simulate("submit", mockedSubmitEvent)
57 | expect( loginUser ).toHaveBeenCalledTimes(1)
58 | })
59 |
60 | })
--------------------------------------------------------------------------------
/src/components/LoginView/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { loginUser, lazyLoginUser } from "../../redux/actions/authActions"
4 | // UI
5 | import LoginView from './LoginView'
6 |
7 | const mapStateToProps = (state) => ({
8 | isAuthenticating : state.auth.isAuthenticating,
9 | isAuthenticated: state.auth.isAuthenticated,
10 | statusText : state.auth.statusText,
11 | })
12 |
13 | const mapDispatchToProps = { loginUser, lazyLoginUser }
14 |
15 | export default connect(
16 | mapStateToProps,
17 | mapDispatchToProps
18 | )(LoginView)
--------------------------------------------------------------------------------
/src/components/MusicPlayer/MusicPlayer.less:
--------------------------------------------------------------------------------
1 | .music-player {
2 | display: flex;
3 | flex-flow: row nowrap;
4 | min-height: 65px;
5 | justify-content: space-around;
6 | align-items: center;
7 | padding-right: 30px;
8 | padding-left: 30px;
9 | }
10 |
11 | /*
12 | * Styles of the song metadata container which includes:
13 | * - album cover
14 | * - song name
15 | * - song artist
16 | * - fav star
17 | */
18 |
19 | /* For XS (mobile) screens, we need to make this container smaller
20 | * (which will result in showing less of the song's name) to make
21 | * room for the currently_playing_controls which require at least 90px.
22 | * To avoid the queue button to look cramped, we also need to reduce
23 | * the paddings
24 | */
25 | @media (max-width: 767px) {
26 | .music-player {
27 | padding-right: 5px;
28 | padding-left: 10px;
29 | }
30 |
31 | .music-player .song_metadata_container {
32 | width: 210px !important;
33 | }
34 | }
35 |
36 | .music-player .song_metadata_container {
37 | width: 255px;
38 | align-items: center;
39 | display: flex;
40 | flex-flow: row;
41 | }
42 |
43 | .music-player .song_metadata_container img {
44 | margin-right: 10px;
45 | cursor: pointer;
46 | }
47 |
48 |
49 | .music-player .song_metadata_container p {
50 | white-space: nowrap;
51 | overflow: hidden;
52 | text-overflow: ellipsis;
53 | }
54 |
55 | .music-player .song_metadata_container .artist-link {
56 | cursor: pointer;
57 | }
58 |
59 | .music-player .song_metadata_container .artist-link:hover {
60 | text-decoration: underline;
61 | }
62 |
63 | /*
64 | * Styles of the currently playing controls which include:
65 | * - play previous song
66 | * - play/pause current song
67 | * - play next song
68 | */
69 |
70 | .music-player .currently_playing_controls i {
71 | width: 90px;
72 | flex: 0 0 90px;
73 | }
74 |
75 | /*
76 | * Styles of the song progress bar which include:
77 | * - current position (minutes:seconds)
78 | * - Progress bar
79 | * - total song's duration (minutes:seconds)
80 | */
81 |
82 | .music-player .song_progress_bar_container {
83 | display: flex;
84 | align-items: center;
85 | }
86 |
87 | .music-player .song_progress_bar_container span {
88 | margin: 0 10px;
89 | }
90 |
91 | .music-player .song_progress_bar_container .song_progress_bar {
92 | flex-grow: 1;
93 | cursor: pointer;
94 | }
95 |
96 | .song_progress_bar {
97 | flex-grow: 1;
98 | }
99 |
100 |
101 | /*
102 | * Styles of the volume controls which include:
103 | * - muting button
104 | * - volume bar
105 | */
106 |
107 | .music-player .volume_controls_container {
108 | display: flex;
109 | flex-direction: row;
110 | align-items: center;
111 | }
112 |
113 | .music-player .volume_control_bar {
114 | width: 100px;
115 | }
116 |
117 | .music-player i.volume_control_mute {
118 | margin-right: 15px;
119 | margin-left: 5px;
120 | }
121 |
--------------------------------------------------------------------------------
/src/components/MusicPlayer/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { playNextSong, playPreviousSong, toggleShuffle } from "../../redux/actions/songsActions"
4 | import { setStarOnSongs } from "../../redux/actions/favouritesActions"
5 | import { getSongCurrentlyPlayingSelector } from '../../redux/selectors/musicPlayerSelector'
6 | // UI
7 | import MusicPlayer from './MusicPlayer'
8 |
9 | const mapStateToProps = (state) => {
10 | return {
11 | "song" : getSongCurrentlyPlayingSelector(state),
12 | "isShuffleOn": state.musicPlayer.isShuffleOn,
13 | }
14 | }
15 |
16 | const mapDispatchToProps = { playNextSong, playPreviousSong, setStarOnSongs, toggleShuffle }
17 |
18 | export default connect(
19 | mapStateToProps,
20 | mapDispatchToProps
21 | )(MusicPlayer)
--------------------------------------------------------------------------------
/src/components/Navbar/Navbar.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import { navigate } from "@reach/router"
4 | // Utils
5 | import * as settings from "../../utils/settings.js"
6 | // UI
7 | import {Navbar, Icon, Nav, Dropdown } from 'rsuite'
8 |
9 | export default class MyNavbar extends React.Component {
10 |
11 | constructor(props) {
12 | super(props)
13 | // Check which items to display in the settings
14 | this.itemsToDisplay = settings.getSidebarDisplaySettings(true)
15 | }
16 |
17 | onNavSelected = (key) => {
18 | switch(key) {
19 | case "newPlaylist":
20 | this.props.onCreatePlaylistTrigger && this.props.onCreatePlaylistTrigger()
21 | break
22 | default:
23 | navigate(key)
24 | }
25 | }
26 |
27 | render() {
28 | const playlists = this.props.playlists
29 | const currentPath = this.props.currentLocation
30 | return (
31 |
32 |
33 |
51 |
54 |
55 |
56 | )
57 | }
58 | }
59 |
60 | MyNavbar.propTypes = {
61 | onCreatePlaylistTrigger : PropTypes.func,
62 | playlists : PropTypes.object.isRequired
63 | }
64 |
65 | MyNavbar.defaultProps = {
66 | playlists : {}
67 | }
68 |
--------------------------------------------------------------------------------
/src/components/Navbar/Navbar.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import MyNavbar from "./Navbar"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing", () => {
8 | shallow( )
9 | })
10 |
11 | it("renders the playlists with their amount of songs", () => {
12 | const playlists = {
13 | "id1" : { name : "one", songCount : 20},
14 | "id2" : { name : "two", songCount : 132},
15 | }
16 | const wrapper = shallow( )
17 | // check there are the 2 playlists + create
18 | expect(wrapper.find("#playlists").children()).toHaveLength(3)
19 | })
20 |
21 | // TODO: couldn't find a way to test the contents of each playlist item to check if they are rendered correctly
22 |
23 | // TODO: couldn't find a way to test the nav items as component.simulate('click') doesn't trigger anything
24 |
25 | })
26 |
--------------------------------------------------------------------------------
/src/components/Navbar/index.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { Location } from "@reach/router"
3 | // Redux
4 | import { connect } from "react-redux"
5 | // UI
6 | import Navbar from './Navbar'
7 |
8 | /* Create a Navbar connected to Reach's location provider */
9 | function HOCNavbar(props) {
10 | const navbarProps = props
11 | return (
12 |
13 | {
14 | props => {
15 | // Get the location from reach's to highlight the active item
16 | const currentPath = props.location.pathname
17 | return
18 | }
19 | }
20 |
21 | )
22 | }
23 |
24 | const mapStateToProps = (state) => {
25 | return {
26 | "playlists" : state.playlists.byId,
27 | }
28 | }
29 |
30 | export default connect(
31 | mapStateToProps,
32 | null
33 | )(HOCNavbar)
--------------------------------------------------------------------------------
/src/components/Playlist/Playlist.less:
--------------------------------------------------------------------------------
1 | .playlist-songs-container {
2 | padding-right: 15px;
3 | padding-left: 15px;
4 | padding-bottom: 15px;
5 | }
6 |
7 | /* For XS (mobile) screens, we need to make this container smaller
8 | * (which will result in showing less of the song's name) to make
9 | * room for the currently_playing_controls which require at least 90px.
10 | * To avoid the queue button to look cramped, we also need to reduce
11 | * the paddings
12 | */
13 | @media (max-width: 767px) {
14 | .playlist-songs-container {
15 | padding: 0px;
16 | }
17 | }
--------------------------------------------------------------------------------
/src/components/Playlist/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { removeSongsFromPlaylist, loadSinglePlaylist } from "../../redux/actions/playlistsActions"
4 | import { songsOfPlaylistSelector } from '../../redux/selectors/songSelectors'
5 | // UI
6 | import Playlist from './Playlist'
7 |
8 | const mapStateToProps = (state, ownProps) => {
9 | return {
10 | "playlist" : state.playlists.byId[ownProps.playlistId],
11 | "songs" : songsOfPlaylistSelector(state, ownProps)
12 | }
13 | }
14 |
15 | const mapDispatchToProps = { removeSongsFromPlaylist, loadSinglePlaylist }
16 |
17 | export default connect(
18 | mapStateToProps,
19 | mapDispatchToProps
20 | )(Playlist)
--------------------------------------------------------------------------------
/src/components/PlaylistSelectorDropdown/PlaylistSelectorDropdown.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import PropTypes from 'prop-types'
3 | // UI
4 | import { Dropdown, Icon } from 'rsuite'
5 |
6 | export default class PlaylistSelectorDropdown extends React.Component {
7 |
8 | constructor(props) {
9 | super(props)
10 | // Create a random key for favourites to avoid constraining the names of the playlists
11 | // i.e. if we assign eventKey="favs", then a playlist could not be named "favs"
12 | this.favourites_key = `${Math.random()}`
13 | this.queue_key = `${Math.random()}`
14 | }
15 |
16 | onItemSelected = (eventKey) => {
17 | if(this.props.playlists[eventKey]) {
18 | this.props.onPlaylistSelected && this.props.onPlaylistSelected(this.props.playlists[eventKey])
19 | }
20 | else if( this.favourites_key === eventKey ) {
21 | this.props.onFavouritesSelected && this.props.onFavouritesSelected()
22 | }
23 | else {
24 | this.props.onQueueSelected && this.props.onQueueSelected()
25 | }
26 | }
27 |
28 | render() {
29 | const showFavourites = this.props.showFavourites
30 | const showQueue = this.props.showQueue
31 | return (
32 |
33 | {
34 | showFavourites ?
35 | }>Favourites
36 | : null
37 | }
38 | {
39 | showQueue ?
40 | }>Queue
41 | : null
42 | }
43 | {
44 | showFavourites ? : null
45 | }
46 | {
47 | Object.keys(this.props.playlists).map(pId =>
48 | this.props.playlists[pId].isMine ?
49 | {this.props.playlists[pId].name}
50 | : null
51 | )
52 | }
53 |
54 | )
55 | }
56 |
57 | }
58 |
59 | PlaylistSelectorDropdown.propTypes = {
60 | playlists : PropTypes.object.isRequired,
61 | onFavouritesSelected : PropTypes.func,
62 | onPlaylistSelected : PropTypes.func,
63 | showFavourites : PropTypes.bool.isRequired,
64 | showQueue : PropTypes.bool.isRequired
65 | }
66 |
67 | PlaylistSelectorDropdown.defaultProps = {
68 | playlists : {},
69 | showFavourites : true,
70 | showQueue : true,
71 | }
72 |
--------------------------------------------------------------------------------
/src/components/PlaylistSelectorDropdown/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | // UI
4 | import PlaylistSelectorDropdown from './PlaylistSelectorDropdown'
5 |
6 | const mapStateToProps = (state) => {
7 | return {
8 | "playlists" : state.playlists.byId,
9 | }
10 | }
11 |
12 | export default connect(
13 | mapStateToProps,
14 | null
15 | )(PlaylistSelectorDropdown)
--------------------------------------------------------------------------------
/src/components/QueueView/QueueView.less:
--------------------------------------------------------------------------------
1 | .queue-songs-container {
2 | padding-right: 15px;
3 | padding-left: 15px;
4 | padding-bottom: 15px;
5 | }
6 |
7 | /* For XS (mobile) screens, we need to make this container smaller
8 | * (which will result in showing less of the song's name) to make
9 | * room for the currently_playing_controls which require at least 90px.
10 | * To avoid the queue button to look cramped, we also need to reduce
11 | * the paddings
12 | */
13 | @media (max-width: 767px) {
14 | .queue-songs-container {
15 | padding: 0px;
16 | }
17 | }
--------------------------------------------------------------------------------
/src/components/QueueView/QueueView.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import Queue from "./QueueView"
4 |
5 | describe("", () => {
6 |
7 | const songs = [
8 | { "id" : "1" },
9 | { "id" : "2" },
10 | ]
11 |
12 | it("renders without crashing", () => {
13 | shallow( )
14 | })
15 |
16 | it("should clear the queue on click", () => {
17 | const clearQueue = jest.fn()
18 | const wrapper = shallow( )
19 | wrapper.find("#clear_button").simulate("click")
20 | expect(clearQueue).toHaveBeenCalledTimes(1)
21 | })
22 |
23 | it("should remove songs on click", () => {
24 | const removeSongsFromQueue = jest.fn()
25 | const wrapper = shallow( )
26 | // Select some songs
27 | const selectedSongs = [songs[0]]
28 | wrapper.find("#songs_table").simulate("songsSelected", selectedSongs)
29 | // Remove them from the queue
30 | wrapper.find("#remove_button").simulate("click")
31 | expect(removeSongsFromQueue).toHaveBeenCalledWith(selectedSongs)
32 | })
33 |
34 | it("should play any song in the queue", () => {
35 | const seekToSongInQueue = jest.fn()
36 | const wrapper = shallow( )
37 | // Click a song
38 | const songClicked = songs[0]
39 | wrapper.find("#songs_table").simulate("songClicked", songClicked)
40 | // Expect it to now play
41 | expect(seekToSongInQueue).toHaveBeenCalledWith(songClicked)
42 | })
43 |
44 | })
--------------------------------------------------------------------------------
/src/components/QueueView/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { clearQueue, removeSongsFromQueue, seekToSongInQueue } from "../../redux/actions/songsActions"
4 | import { getSongsInQueueSelector } from '../../redux/selectors/musicPlayerSelector'
5 | // UI
6 | import QueueView from './QueueView'
7 |
8 | const mapStateToProps = (state, ownProps) => {
9 | return {
10 | "songs" : getSongsInQueueSelector(state, ownProps)
11 | }
12 | }
13 |
14 | const mapDispatchToProps = { clearQueue, removeSongsFromQueue, seekToSongInQueue }
15 |
16 | export default connect(
17 | mapStateToProps,
18 | mapDispatchToProps
19 | )(QueueView)
--------------------------------------------------------------------------------
/src/components/RecentlyAddedView/RecentlyAddedView.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import PropTypes from 'prop-types'
3 | // Utils
4 | import subsonic from "../../api/subsonicApi"
5 | // UI
6 | import { Grid, Row, Col } from 'rsuite'
7 | import AlbumResult from "../SearchAlbumResult"
8 | import "./RecentlyAddedView.less"
9 |
10 | export default class RecentlyAddedView extends React.Component {
11 |
12 | constructor(props) {
13 | super(props)
14 | this.state = {albums : []}
15 | }
16 |
17 | componentDidMount = async () => {
18 | // Get the newest albums from Subsonic
19 | this.props.beginAsyncTask()
20 | try {
21 | const albums = await subsonic.getAlbumList2("newest")
22 | this.props.asyncTaskSuccess()
23 | this.setState({albums})
24 | }
25 | catch(err) {
26 | console.log(err)
27 | this.props.asyncTaskError("Unable to load recently added albums")
28 | }
29 | }
30 |
31 | render() {
32 | const albums = this.state.albums
33 | return (
34 |
35 |
36 |
37 | Recently Added
38 |
39 |
40 | { albums.map(a =>
41 |
44 | )}
45 |
46 |
47 |
48 | )
49 | }
50 | }
51 |
52 | RecentlyAddedView.propTypes = {
53 | beginAsyncTask: PropTypes.func,
54 | asyncTaskSuccess: PropTypes.func,
55 | asyncTaskError : PropTypes.func,
56 | }
57 |
58 | RecentlyAddedView.defaultProps = {
59 | beginAsyncTask: () => null,
60 | asyncTaskSuccess: () => null,
61 | asyncTaskError : () => null,
62 | }
63 |
--------------------------------------------------------------------------------
/src/components/RecentlyAddedView/RecentlyAddedView.less:
--------------------------------------------------------------------------------
1 | .result-grid-container {
2 | display: flex;
3 | flex-flow: row wrap;
4 | justify-content: center;
5 | }
6 |
7 | .result-grid-container .result-item {
8 | flex-basis: 16.6%;
9 | -ms-flex: auto;
10 | position: relative;
11 | padding: 10px;
12 | box-sizing: border-box;
13 | }
--------------------------------------------------------------------------------
/src/components/RecentlyAddedView/RecentlyAddedView.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import RecentlyAddedView from "./RecentlyAddedView"
4 | // Mocking redux
5 | import configureMockStore from 'redux-mock-store'
6 | import thunk from 'redux-thunk'
7 | import fetchMock from 'fetch-mock'
8 |
9 | const middlewares = [thunk]
10 | const mockStore = configureMockStore(middlewares)
11 |
12 | describe("", () => {
13 |
14 | afterEach(() => {
15 | fetchMock.restore()
16 | })
17 |
18 | it("renders without crashing", () => {
19 | shallow( )
20 | })
21 |
22 | it("should load the latest albums and update API calls' status", () => {
23 | // Mock API call
24 | const mockResponse = {"subsonic-response": {"albumList2": {"album" : []}, "status": "ok","version": "1.9.0"}}
25 | fetchMock.getOnce('*', {
26 | body: mockResponse,
27 | headers: { 'content-type': 'application/json' }
28 | })
29 | // Mount component
30 | const beginAsyncTask = jest.fn()
31 | const asyncTaskSuccess = jest.fn()
32 | shallow( )
33 | // Expect calls on success
34 | expect(beginAsyncTask).toHaveBeenCalledTimes(1)
35 | // TODO: how to test this? This probably means that SubsonicAPI should be mocked separately
36 | // or that the api call should be made somewhere else
37 | // expect(asyncTaskSuccess).toHaveBeenCalledTimes(1)
38 | })
39 |
40 | })
--------------------------------------------------------------------------------
/src/components/RecentlyAddedView/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import {beginAsyncTask, asyncTaskSuccess, asyncTaskError } from "../../redux/actions/apiStatusActions"
4 | // UI
5 | import RecentlyAddedView from './RecentlyAddedView'
6 |
7 | const mapDispatchToProps = { beginAsyncTask, asyncTaskSuccess, asyncTaskError }
8 |
9 | export default connect(
10 | null,
11 | mapDispatchToProps
12 | )(RecentlyAddedView)
--------------------------------------------------------------------------------
/src/components/ResponsiveTitle/ResponsiveTitle.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import PropTypes from 'prop-types'
3 |
4 | function ResponsiveTitle(props) {
5 | return (
6 | <>
7 | {props.children}
8 | {props.children}
9 | >
10 | )
11 | }
12 |
13 | ResponsiveTitle.propTypes = {
14 | style : PropTypes.object,
15 | children : PropTypes.any
16 | }
17 |
18 | export default ResponsiveTitle
19 |
20 |
--------------------------------------------------------------------------------
/src/components/ResponsiveTitle/ResponsiveTitle.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import ResponsiveTitle from "./ResponsiveTitle"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing", () => {
8 | shallow( )
9 | })
10 |
11 | })
--------------------------------------------------------------------------------
/src/components/ResponsiveTitle/index.js:
--------------------------------------------------------------------------------
1 | // UI
2 | import ResponsiveTitle from './ResponsiveTitle'
3 |
4 | export default ResponsiveTitle
--------------------------------------------------------------------------------
/src/components/ScrobbleSetting/ScrobbleSetting.js:
--------------------------------------------------------------------------------
1 | import React, {useEffect, useState} from "react"
2 | import { Toggle } from 'rsuite'
3 | // settings
4 | import * as settings from "../../utils/settings"
5 |
6 | export default function ScrobbleSetting(props) {
7 | const [value, setValue] = useState(true)
8 |
9 | // load from settings
10 | useEffect(() => {
11 | const isScrobbling = settings.getIsScrobbling()
12 | setValue(isScrobbling)
13 | }, [])
14 |
15 | // update settings
16 | function update_settings(value) {
17 | settings.setIsScrobbling(value)
18 | setValue(value)
19 | }
20 |
21 | return (
22 |
23 | Scrobble?
24 |
25 | )
26 | }
--------------------------------------------------------------------------------
/src/components/ScrobbleSetting/ScrobbleSetting.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow } from 'enzyme'
3 | import ScrobbleSetting from "./ScrobbleSetting"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing", () => {
8 | shallow( )
9 | })
10 |
11 | })
--------------------------------------------------------------------------------
/src/components/ScrobbleSetting/index.js:
--------------------------------------------------------------------------------
1 | // UI
2 | import ScrobbleSetting from './ScrobbleSetting'
3 |
4 | export default ScrobbleSetting
--------------------------------------------------------------------------------
/src/components/SearchAlbumResult/SearchAlbumResult.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { navigate } from "@reach/router"
3 | import PropTypes from 'prop-types'
4 |
5 | import { Icon, Badge } from 'rsuite'
6 | import subsonic from "../../api/subsonicApi"
7 |
8 | import "./SearchAlbumResult.less"
9 |
10 | export default function SearchAlbumResult(props) {
11 | const {album, showYear} = props
12 | return (
13 | {navigate("/album/"+album.id)} }>
14 |
: false}>
15 |
16 |

17 |
18 |
19 |
20 | {album.name}
21 | {album.artist}
22 | { showYear && ` (${album.year})` }
23 |
24 |
25 | )
26 | }
27 |
28 | SearchAlbumResult.propTypes = {
29 | album : PropTypes.object.isRequired,
30 | showYear: PropTypes.bool
31 | }
32 |
33 | SearchAlbumResult.defaultProps = {
34 | showYear: false
35 | }
--------------------------------------------------------------------------------
/src/components/SearchAlbumResult/SearchAlbumResult.less:
--------------------------------------------------------------------------------
1 | .link_to_album {
2 | padding : 10px 5px;
3 | transition: color .3s linear, background-color .3s linear;
4 | transition-property: color, background-color;
5 | transition-duration: 0.3s, 0.3s;
6 | transition-timing-function: linear, linear;
7 | transition-delay: 0s, 0s;
8 | }
9 |
10 | /* Remove padding for small screens */
11 | @media (max-width: 767px) {
12 | .link_to_album {
13 | padding: 0px;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/SearchAlbumResult/SearchAlbumResult.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import SearchAlbumResult from "./SearchAlbumResult"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing", () => {
8 | const album = {id : "a1", name : "Album", artist : "Artist"}
9 | shallow( )
10 | })
11 |
12 | it("renders an indicator for starred albums without crashing", () => {
13 | const album = {id : "a1", name : "Album", artist : "Artist", starred:true}
14 | shallow( )
15 | })
16 |
17 | it("shows the year of the album", () => {
18 | const album = {id : "a1", name : "Album", artist : "Artist", year:"2020"}
19 | const wrapper = shallow( )
20 | expect(wrapper.find("[data-key='description']").text().includes(album.year)).toBeTruthy()
21 | })
22 |
23 | })
--------------------------------------------------------------------------------
/src/components/SearchAlbumResult/index.js:
--------------------------------------------------------------------------------
1 | import SearchAlbumResult from './SearchAlbumResult'
2 |
3 | export default SearchAlbumResult
--------------------------------------------------------------------------------
/src/components/SearchBar/SearchBar.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import PropTypes from 'prop-types'
3 | // UI
4 | import { Input, InputGroup, Icon } from 'rsuite'
5 |
6 | export default class SearchBar extends React.PureComponent {
7 |
8 | performSearch = () => {
9 | this.props.onSearch && this.props.onSearch(this.query)
10 | }
11 |
12 | handleKeyDown = (e) => {
13 | if( e.key === "Enter" ) {
14 | this.performSearch()
15 | }
16 | }
17 |
18 | render() {
19 | return (
20 |
21 | {this.query = value})} onKeyDown={this.handleKeyDown} />
22 |
23 |
24 | )
25 | }
26 | }
27 |
28 | SearchBar.propTypes = {
29 | size : PropTypes.string,
30 | onSearch : PropTypes.func.isRequired
31 | }
32 |
33 | SearchBar.defaultProps = {
34 | size : "lg",
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/SearchBar/SearchBar.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import SearchBar from "./SearchBar"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing", () => {
8 | shallow( null} /> )
9 | })
10 |
11 | it("should let me perform a search", () => {
12 | const onSearch = jest.fn()
13 | const wrapper = shallow( )
14 | // Try to query something
15 | wrapper.find("#queryBar").simulate("change", "my query")
16 | wrapper.find("#searchButton").simulate("click")
17 | // Check that the search function is called
18 | expect(onSearch).toHaveBeenCalledWith("my query")
19 | })
20 |
21 | })
--------------------------------------------------------------------------------
/src/components/SearchBar/index.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { navigate } from "@reach/router"
3 | // Redux
4 | import { connect } from "react-redux"
5 | import { search } from "../../redux/actions/searchActions"
6 | // UI
7 | import SearchBar from './SearchBar'
8 |
9 | /* Create a SearchBar connected to the store to automatically perform searches in the App */
10 | class HOCSearchBar extends React.Component {
11 |
12 | performSearch = (query) => {
13 | if( query ) {
14 | this.props.search(query)
15 | navigate("/search")
16 | }
17 | }
18 |
19 | render = () =>
20 | }
21 |
22 | export default connect(null, { search })(HOCSearchBar)
--------------------------------------------------------------------------------
/src/components/SearchSongResult/SearchSongResult.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | // UI
4 | import SongsTable from '../SongsTable/SongsTable'
5 | import SongsTableEnhanced from '../SongsTableEnhanced'
6 |
7 | const SONG_COLUMNS_TO_SHOW = [SongsTable.columns.selectable, SongsTable.columns.title, SongsTable.columns.artist, SongsTable.columns.album, SongsTable.columns.duration, SongsTable.columns.bitRate, SongsTable.columns.download]
8 |
9 | export default function SearchSongResult(props) {
10 | const songs = props.songs
11 | return (
12 |
13 | )
14 | }
15 |
16 | SearchSongResult.propTypes = {
17 | songs : PropTypes.array.isRequired
18 | }
--------------------------------------------------------------------------------
/src/components/SearchSongResult/SearchSongResult.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import SearchSongResult from "./SearchSongResult"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing", () => {
8 | const songs = [{id : 1}]
9 | shallow( )
10 | })
11 |
12 | })
--------------------------------------------------------------------------------
/src/components/SearchSongResult/index.js:
--------------------------------------------------------------------------------
1 | import SearchSongResult from './SearchSongResult'
2 |
3 | export default SearchSongResult
--------------------------------------------------------------------------------
/src/components/SearchView/SearchView.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import PropTypes from 'prop-types'
3 | // Results
4 | import AlbumResult from "../SearchAlbumResult"
5 | import ArtistElement from "../ArtistListElement"
6 | import SongsResult from "../SearchSongResult"
7 | // UI
8 | import SearchBar from "../SearchBar"
9 | import { Col } from 'rsuite'
10 |
11 | export default class SearchView extends React.Component {
12 |
13 | render() {
14 | const albums = this.props.albums || []
15 | const artists = this.props.artists || []
16 | const songs = this.props.songs || []
17 | return (
18 |
19 |
20 |
21 |
22 |
23 | {
24 | /* Case where there are no results */
25 | (artists.length === 0 && albums.length === 0 && songs.length === 0)
26 | ?
No results
27 | : null
28 | }
29 |
30 | {
31 | /* Artists section */
32 | artists.length > 0 ?
33 |
34 | Artists
35 |
36 | { artists.map( a =>
)}
37 |
38 |
39 | : null
40 | }
41 |
42 | {
43 | albums.length > 0 ? (
44 |
45 | Albums
46 |
47 | { albums.map(a =>
48 |
51 | )}
52 |
53 |
54 | ) : null
55 | }
56 |
57 | {
58 | songs.length > 0 ? (
59 |
60 | Songs
61 |
62 |
63 | ) : null
64 | }
65 |
66 |
67 | )
68 | }
69 | }
70 |
71 | SearchView.propTypes = {
72 | "artists" : PropTypes.array,
73 | "albums" : PropTypes.array,
74 | "songs" : PropTypes.array,
75 | }
76 |
--------------------------------------------------------------------------------
/src/components/SearchView/SearchView.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import SearchView from "./SearchView"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing when no results are available", () => {
8 | shallow( )
9 | })
10 |
11 | it("renders without crashing when results are available", () => {
12 | const artists = [{id : "a1", name : "artist1"}]
13 | const albums = [{id : "a1", name : "album1"}]
14 | const songs = [{id : "s1", name : "song1"}]
15 | shallow( )
16 | })
17 |
18 | })
--------------------------------------------------------------------------------
/src/components/SearchView/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { search } from "../../redux/actions/searchActions"
4 | import { searchSongsSelector } from '../../redux/selectors/searchSelectors'
5 | // UI
6 | import SearchView from './SearchView'
7 |
8 | const mapStateToProps = (state, ownProps) => {
9 | return {
10 | "artists" : state.search.artists,
11 | "albums" : state.search.albums,
12 | "songs" : searchSongsSelector(state)
13 | }
14 | }
15 |
16 | const mapDispatchToProps = { search }
17 |
18 |
19 | export default connect(
20 | mapStateToProps,
21 | mapDispatchToProps
22 | )(SearchView)
--------------------------------------------------------------------------------
/src/components/SettingsView/SettingsView.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | // UI
4 | import ThemePicker from '../ThemePicker'
5 | import SidebarSettings from '../SidebarSettings'
6 | import ScrobbleSetting from '../ScrobbleSetting'
7 | import { Button, Row, Col, Divider } from 'rsuite'
8 | // Utils
9 | import * as utils from "../../utils/theming"
10 |
11 | export default class SettingsView extends React.Component {
12 |
13 | constructor(props){
14 | super(props)
15 | this.themes = utils.getAvailableThemes()
16 | }
17 |
18 | onLogOut = () => {
19 | this.props.logout()
20 | }
21 |
22 | render() {
23 | const themes = this.themes
24 | return (
25 |
26 |
Settings
|
27 | {/*Scrobble*/}
28 |
29 | {/* Theme picker */}
30 |
Theme Picker
31 |
32 |
33 |
34 | {/* Items to display in the sidebar */}
35 |
Sidebar settings
36 |
37 |
38 |
39 | {/*Log out*/}
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | )
53 | }
54 |
55 | }
56 |
57 | SettingsView.propTypes = {
58 | logout : PropTypes.func.isRequired
59 | }
60 |
--------------------------------------------------------------------------------
/src/components/SettingsView/SettingsView.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import SettingsView from "./SettingsView"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing", () => {
8 | shallow( null} /> )
9 | })
10 |
11 | it("should let me log out", () => {
12 | const logout = jest.fn()
13 | const wrapper = shallow( )
14 | wrapper.find("#logoutButton").simulate("click")
15 | expect(logout).toHaveBeenCalledTimes(1)
16 | })
17 |
18 | })
--------------------------------------------------------------------------------
/src/components/SettingsView/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { logout } from "../../redux/actions/authActions"
4 | // UI
5 | import SettingsView from './SettingsView'
6 |
7 | const mapDispatchToProps = { logout }
8 |
9 | export default connect(
10 | null,
11 | mapDispatchToProps
12 | )(SettingsView)
--------------------------------------------------------------------------------
/src/components/Sidebar/Sidebar.less:
--------------------------------------------------------------------------------
1 | .subsidebar {
2 | height: 100%;
3 | padding: 10px;
4 | display: flex;
5 | flex-direction: column;
6 | }
7 |
8 | .subsidebar .playlists-container {
9 | flex-grow: 1;
10 | overflow: auto;
11 | }
12 |
13 | .subsidebar .section-header {
14 | margin-top: 12px;
15 | margin-bottom: 12px;
16 | }
17 |
18 | .subsidebar .footer-divider {
19 | margin: 15px 0 !important;
20 | }
--------------------------------------------------------------------------------
/src/components/Sidebar/Sidebar.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import Sidebar from "./Sidebar"
4 |
5 | describe("", () => {
6 | const playlists = {
7 | "p1" : {
8 | name : "name",
9 | songCount : 10
10 | },
11 | "p2" : {
12 | name : "name 2",
13 | songCount : 14
14 | }
15 | }
16 |
17 | it("renders without crashing", () => {
18 | shallow( )
19 | })
20 |
21 | it("should render a list of playlists", () => {
22 | const wrapper = shallow( )
23 | // Get playlists visible
24 | expect(wrapper.find("[data-pid='p1']")).toHaveLength(1)
25 | expect(wrapper.find("[data-pid='p2']")).toHaveLength(1)
26 | })
27 |
28 | it("should let me create new playlists", () => {
29 | const onCreatePlaylistTrigger = jest.fn()
30 | const wrapper = shallow( )
31 | // Try to create playlist
32 | wrapper.find("#createPlaylistButton").simulate('click')
33 | expect(onCreatePlaylistTrigger).toHaveBeenCalledTimes(1)
34 | })
35 |
36 | })
37 |
--------------------------------------------------------------------------------
/src/components/Sidebar/index.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { Location } from "@reach/router"
3 | // Redux
4 | import { connect } from "react-redux"
5 | // UI
6 | import Sidebar from './Sidebar'
7 |
8 | /* Create a Sidebar connected to Reach's location provider */
9 | function HOCSidebar(props) {
10 | const sidebarProps = props
11 | return (
12 |
13 | {
14 | props => {
15 | // Get the location from reach's to highlight the active item
16 | const currentPath = props.location.pathname
17 | return
18 | }
19 | }
20 |
21 | )
22 | }
23 |
24 |
25 | const mapStateToProps = (state) => {
26 | return {
27 | "playlists" : state.playlists.byId,
28 | }
29 | }
30 |
31 | export default connect(
32 | mapStateToProps,
33 | null
34 | )(HOCSidebar)
--------------------------------------------------------------------------------
/src/components/SidebarSettings/SidebarSettings.js:
--------------------------------------------------------------------------------
1 | import React, {useEffect, useState} from "react"
2 | import { CheckboxGroup, Checkbox } from 'rsuite'
3 | // settings
4 | import * as settings from "../../utils/settings"
5 |
6 | export default function SidebarSettings(props) {
7 | const allOptions = settings.POSSIBLE_SIDEBAR_LINKS
8 | const [value, setValue] = useState([])
9 |
10 | // load from settings
11 | useEffect(() => {
12 | setValue(settings.getSidebarDisplaySettings().map(s => s.key))
13 | }, [])
14 |
15 | // update settings
16 | function update_settings(newValue) {
17 | setValue(newValue)
18 | settings.setSidebarDisplaySettings(allOptions.filter(o => newValue.includes(o.key)))
19 | }
20 |
21 | return (
22 |
23 |
24 | Select the items to display in the sidebar (NOTE: you need to refresh the site for these changes to take effect)
25 | {
26 | allOptions.map(option => (
27 |
28 | {option.text}
29 |
30 | ))
31 | }
32 |
33 |
34 | )
35 | }
--------------------------------------------------------------------------------
/src/components/SidebarSettings/SidebarSettings.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import SidebarSettings from "./SidebarSettings"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing", () => {
8 | shallow( )
9 | })
10 |
11 | it("should change the settings of the sidebar", () => {
12 | const loadSongsOfGenre = jest.fn()
13 | const wrapper = shallow( )
14 | // change the values and expect no crash
15 | wrapper.find("[name='genresCheckboxList']").simulate("change", [])
16 | })
17 |
18 | })
--------------------------------------------------------------------------------
/src/components/SidebarSettings/index.js:
--------------------------------------------------------------------------------
1 | // UI
2 | import SidebarSettings from './SidebarSettings'
3 |
4 | export default SidebarSettings
--------------------------------------------------------------------------------
/src/components/SongsTable/SongsTable.less:
--------------------------------------------------------------------------------
1 | i.icon-when-playing {
2 | display: none;
3 | }
4 |
5 | .currently-playing i.icon-when-playing {
6 | display: initial;
7 | }
--------------------------------------------------------------------------------
/src/components/SongsTable/SongsTable.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import SongsTable from "./SongsTable"
4 |
5 | describe("", () => {
6 | const songs = [...Array(500).keys()].map(i => {return {
7 | id : i.toString(),
8 | title: `song ${i}`,
9 | artist : `artist ${Math.random().toString(36).substring(7)}`,
10 | album : `album ${i}`,
11 | starred: i%2 == 0
12 | }})
13 |
14 | it("renders without crashing", () => {
15 | shallow( )
16 | })
17 |
18 | it("renders a list of songs without crashing", () => {
19 | const wrapper = shallow( )
20 | })
21 |
22 | it("should start playing the list of songs when an item is clicked", () => {
23 | const putSongsInQueue = jest.fn()
24 | const wrapper = shallow( )
25 | wrapper.find("#songsTable").simulate("rowClick", songs[0])
26 | expect(putSongsInQueue).toHaveBeenCalledTimes(1)
27 | })
28 |
29 | it("should let me select a single song", () => {
30 | const onSongsSelected = jest.fn()
31 | const wrapper = shallow( )
32 | wrapper.find("#checkColumn").simulate("change", songs[0])
33 | expect(onSongsSelected).toHaveBeenCalledTimes(1)
34 | })
35 |
36 | it("should let me select all songs", () => {
37 | const onSongsSelected = jest.fn()
38 | const wrapper = shallow( )
39 | wrapper.find("#checkAllCell").simulate("change", songs[0])
40 | expect(onSongsSelected).toHaveBeenCalledTimes(1)
41 | })
42 |
43 | it("should let me change the sorting of the songs", () => {
44 | const onSongsSorted = jest.fn()
45 | const wrapper = shallow( )
46 | wrapper.find("#songsTable").simulate("sortColumn", "artist", "asc")
47 | // TODO: check sorting. How to deal with the async call without explicitly calling the method?
48 | })
49 |
50 | it("should let me apply a filter on the songs", () => {
51 | const wrapper = shallow( )
52 | wrapper.setProps({songsFilter: "5"})
53 | // TODO: check filtering. How to deal with the async call without explicitly calling the method?
54 | })
55 |
56 | it("should let me overwrite the default click action", () => {
57 | const onSongClicked = jest.fn()
58 | const wrapper = shallow( )
59 | wrapper.find("#songsTable").simulate("rowClick", songs[0])
60 | expect(onSongClicked).toHaveBeenCalledTimes(1)
61 | })
62 |
63 | })
--------------------------------------------------------------------------------
/src/components/SongsTable/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { putSongsInQueue } from "../../redux/actions/songsActions"
4 | import { getSongCurrentlyPlayingSelector } from '../../redux/selectors/musicPlayerSelector'
5 | // UI
6 | import SongsTable from './SongsTable'
7 |
8 | const mapStateToProps = (state) => {
9 | return {
10 | currentSongPlaying : getSongCurrentlyPlayingSelector(state)
11 | }
12 | }
13 |
14 | const mapDispatchToProps = { putSongsInQueue }
15 |
16 | export default connect(
17 | mapStateToProps,
18 | mapDispatchToProps
19 | )(SongsTable)
--------------------------------------------------------------------------------
/src/components/SongsTableEnhanced/index.js:
--------------------------------------------------------------------------------
1 | // Redux
2 | import { connect } from "react-redux"
3 | import { addSongsToPlaylist } from "../../redux/actions/playlistsActions"
4 | import { addSongsToQueue, putSongsInQueue } from "../../redux/actions/songsActions"
5 | import { setStarOnSongs } from "../../redux/actions/favouritesActions"
6 | // UI
7 | import SongsTableEnhanced from './SongsTableEnhanced'
8 |
9 | const mapDispatchToProps = { addSongsToQueue, addSongsToPlaylist, setStarOnSongs, playAllSongs: putSongsInQueue }
10 |
11 | export default connect(
12 | null,
13 | mapDispatchToProps
14 | )(SongsTableEnhanced)
15 |
--------------------------------------------------------------------------------
/src/components/ThemePicker/ThemePicker.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | // UI
4 | import { Avatar, Col } from 'rsuite'
5 | import "./ThemePicker.less"
6 | // Utils
7 | import * as utils from "../../utils/theming"
8 |
9 | export default class ThemePicker extends React.Component {
10 |
11 | render() {
12 | const themes = this.props.themes || {}
13 | return (
14 | <>
15 | {
16 | Object.keys(themes).map( name => (
17 | {utils.changeTheme(name)} }>
18 |
19 | {utils.formatName(name)}
20 |
21 | ))
22 | }
23 | >
24 | )
25 | }
26 |
27 | }
28 |
29 | ThemePicker.propTypes = {
30 | themes : PropTypes.object
31 | }
--------------------------------------------------------------------------------
/src/components/ThemePicker/ThemePicker.less:
--------------------------------------------------------------------------------
1 | .theme-element {
2 | padding : 15px 20px;
3 | transition: color .3s linear, background-color .3s linear;
4 | transition-property: color, background-color;
5 | transition-duration: 0.3s, 0.3s;
6 | transition-timing-function: linear, linear;
7 | transition-delay: 0s, 0s;
8 | }
9 |
10 | .theme-element .theme-element-color-light {
11 | border: 3px solid #fff;
12 | }
13 |
14 | .theme-element .theme-element-color-dark {
15 | border: 3px solid #0f131a;
16 | }
--------------------------------------------------------------------------------
/src/components/ThemePicker/ThemePicker.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import ThemePicker from "./ThemePicker"
4 |
5 | describe("", () => {
6 |
7 | it("renders without crashing", () => {
8 | shallow( )
9 | })
10 |
11 | it("should show a list of themes", () => {
12 | const themes = { "lightOrange" : {"base-color" : "#fff"}, "darkBlue" : {}, "black" : {} }
13 | const wrapper = shallow( )
14 | // Look for themes
15 | expect(wrapper.find("[data-theme-name='lightOrange']").text()).toBe("Light orange")
16 | expect(wrapper.find("[data-theme-name='darkBlue']").text()).toBe("Dark blue")
17 | expect(wrapper.find("[data-theme-name='black']").text()).toBe("black")
18 | })
19 |
20 | })
--------------------------------------------------------------------------------
/src/components/ThemePicker/index.js:
--------------------------------------------------------------------------------
1 | // UI
2 | import ThemePicker from './ThemePicker'
3 |
4 | export default ThemePicker
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import * as serviceWorker from './serviceWorker'
4 | // Main component
5 | import Main from "./Main.js"
6 | // Theming
7 | import * as theming from "./utils/theming"
8 |
9 | // Init app
10 | theming.initTheme()
11 | ReactDOM.render(
12 | ,
13 | document.getElementById('root')
14 | )
15 |
16 | // If you want your app to work offline and load faster, you can change
17 | // unregister() to register() below. Note this comes with some pitfalls.
18 | // Learn more about service workers: https://bit.ly/CRA-PWA
19 | serviceWorker.unregister()
--------------------------------------------------------------------------------
/src/redux/actions/_tests_/apiStatusActions.test.js:
--------------------------------------------------------------------------------
1 | // Own imports to test
2 | import * as actions from '../apiStatusActions'
3 | import * as types from '../actionTypes'
4 | import * as alerts from '../alertsActions'
5 |
6 | describe('async tasks actions', () => {
7 |
8 | it('creates BEGIN_ASYNC_OPERATION when an async task is started', () => {
9 | expect(actions.beginAsyncTask()).toEqual({ type: types.BEGIN_ASYNC_OPERATION })
10 | })
11 |
12 | it('creates END_ASYNC_OPERATION when an async task is finished successfully', () => {
13 | expect(actions.asyncTaskSuccess()).toEqual({ type: types.END_ASYNC_OPERATION })
14 | })
15 |
16 | it('creates END_ASYNC_OPERATION with an alert to show when an async task is finished successfully with a message', () => {
17 | const message = "message"
18 | expect(actions.asyncTaskSuccess(message)).toEqual({ type: types.END_ASYNC_OPERATION, ...alerts.alertSuccessObject(message) })
19 | })
20 |
21 | it('creates END_ASYNC_OPERATION when an async task is finished with a warning', () => {
22 | expect(actions.asyncTaskWarning()).toEqual({ type: types.END_ASYNC_OPERATION })
23 | })
24 |
25 | it('creates END_ASYNC_OPERATION with an alert to show when an async task is finished with a warning and a message', () => {
26 | const message = "message"
27 | expect(actions.asyncTaskWarning(message)).toEqual({ type: types.END_ASYNC_OPERATION, ...alerts.alertWarningObject(message) })
28 | })
29 |
30 | it('creates END_ASYNC_OPERATION when an async task is finished with an error', () => {
31 | expect(actions.asyncTaskError()).toEqual({ type: types.END_ASYNC_OPERATION })
32 | })
33 |
34 | it('creates END_ASYNC_OPERATION with an alert to show when an async task is finished with an error and a message', () => {
35 | const message = "message"
36 | expect(actions.asyncTaskError(message)).toEqual({ type: types.END_ASYNC_OPERATION, ...alerts.alertErrorObject(message) })
37 | })
38 |
39 | })
--------------------------------------------------------------------------------
/src/redux/actions/_tests_/genresActions.test.js:
--------------------------------------------------------------------------------
1 | // Mocking redux
2 | import configureMockStore from 'redux-mock-store'
3 | import thunk from 'redux-thunk'
4 | import fetchMock from 'fetch-mock'
5 | // Own imports to test
6 | import * as actions from '../genresActions'
7 | import * as types from '../actionTypes'
8 | import * as alerts from "../alertsActions"
9 |
10 | const middlewares = [thunk]
11 | const mockStore = configureMockStore(middlewares)
12 |
13 | describe('genres actions', () => {
14 |
15 | afterEach(() => {
16 | fetchMock.restore()
17 | })
18 |
19 | it('creates LOAD_SONGS_OF_GENRE_SUCCESS when fetching all songs of one genre', () => {
20 | // Fetch all artists
21 | fetchMock.get('*', {
22 | body: {"subsonic-response": { "status" : "ok", "songsByGenre" : { "song" : [1,2,3] } }, "status": "ok","version": "1.9.0"},
23 | headers: { 'content-type': 'application/json' }
24 | })
25 |
26 | const expectedActions = [
27 | { type: types.BEGIN_ASYNC_OPERATION },
28 | { type: types.LOAD_SONGS_OF_GENRE_SUCCESS, payload : { songs : [1,2,3,1,2,3] } },
29 | { type: types.END_ASYNC_OPERATION },
30 | ]
31 |
32 | const store = mockStore({})
33 | return store.dispatch(actions.loadSongsOfGenre({value:"blues", songCount:600})).then(() => {
34 | // return of async actions
35 | const actions = store.getActions()
36 | expect(actions).toEqual(expectedActions)
37 | })
38 | })
39 |
40 | it('Create an error message when failed to fetch songs of a genre', () => {
41 | fetchMock.get('*', 500)
42 |
43 | const expectedActions = [
44 | { type: types.BEGIN_ASYNC_OPERATION },
45 | { type: types.END_ASYNC_OPERATION, alert : { type : alerts.ALERT_TYPE_ERROR } }
46 | ]
47 |
48 | const store = mockStore({})
49 | return store.dispatch(actions.loadSongsOfGenre({value:"rock", songCount:200})).then(() => {
50 | // return of async actions
51 | const actions = store.getActions()
52 | expect(actions).toMatchObject(expectedActions)
53 | })
54 | })
55 |
56 |
57 | })
--------------------------------------------------------------------------------
/src/redux/actions/_tests_/searchActions.test.js:
--------------------------------------------------------------------------------
1 | // Mocking redux
2 | import configureMockStore from 'redux-mock-store'
3 | import thunk from 'redux-thunk'
4 | import fetchMock from 'fetch-mock'
5 | // Own imports to test
6 | import * as alerts from "../alertsActions"
7 | import * as actions from '../searchActions'
8 | import * as types from '../actionTypes'
9 |
10 | const middlewares = [thunk]
11 | const mockStore = configureMockStore(middlewares)
12 |
13 | describe('search actions', () => {
14 |
15 | afterEach(() => {
16 | fetchMock.restore()
17 | })
18 |
19 | it('creates a SEARCH_RESULT when successfully performing a search', () => {
20 | fetchMock.getOnce('*', {
21 | body: {"subsonic-response": { "status" : "ok", "searchResult3" : {} }, "status": "ok","version": "1.9.0"},
22 | headers: { 'content-type': 'application/json' }
23 | })
24 |
25 | const expectedActions = [
26 | { type: types.BEGIN_ASYNC_OPERATION },
27 | { type: types.SEARCH_RESULT, payload : {} },
28 | { type: types.END_ASYNC_OPERATION }
29 | ]
30 |
31 | const store = mockStore({})
32 | return store.dispatch(actions.search("query")).then(() => {
33 | // return of async actions
34 | expect(store.getActions()).toMatchObject(expectedActions)
35 | })
36 | })
37 |
38 | it('creates an ERROR alert when failing to search', () => {
39 | fetchMock.getOnce('*', 500)
40 |
41 | const expectedActions = [
42 | { type: types.BEGIN_ASYNC_OPERATION },
43 | { type: types.END_ASYNC_OPERATION, alert : { type : alerts.ALERT_TYPE_ERROR } }
44 | ]
45 |
46 | const store = mockStore({})
47 | return store.dispatch(actions.search("query")).then(() => {
48 | // return of async actions
49 | expect(store.getActions()).toMatchObject(expectedActions)
50 | })
51 | })
52 |
53 | })
--------------------------------------------------------------------------------
/src/redux/actions/_tests_/songsActions.test.js:
--------------------------------------------------------------------------------
1 | // Own imports to test
2 | import * as actions from '../songsActions'
3 | import * as types from '../actionTypes'
4 | import * as alerts from "../alertsActions"
5 |
6 | describe('songs actions', () => {
7 |
8 | it('creates an PUT_SONGS_IN_QUEUE when wanting to put new songs in the queue', () => {
9 | const songs = [{id : '1'}]
10 | expect(actions.putSongsInQueue(songs)).toMatchObject({ type: types.PUT_SONGS_IN_QUEUE, payload : { songs } })
11 | })
12 |
13 | it('creates a PLAY_NEXT_SONG when wanting to play the next song in the queue', () => {
14 | expect(actions.playNextSong()).toMatchObject({ type: types.PLAY_NEXT_SONG })
15 | })
16 |
17 | it('creates a PLAY_PREVIOUS_SONG when wanting to play the previous song in the queue', () => {
18 | expect(actions.playPreviousSong()).toMatchObject({ type: types.PLAY_PREVIOUS_SONG })
19 | })
20 |
21 | it('creates an ADD_SONGS_TO_QUEUE when wanting to add new songs to the current queue with a message to display', () => {
22 | const songs = [{id : '1'}]
23 | expect(actions.addSongsToQueue(songs)).toMatchObject({ type: types.ADD_SONGS_TO_QUEUE, ...alerts.alertSuccessObject("1 songs added to the queue!"), payload : { songs } })
24 | })
25 |
26 | it('creates a TOGGLE_SHUFFLE_ON when shuffle is turned on', () => {
27 | expect(actions.toggleShuffle(true)).toMatchObject({ type: types.TOGGLE_SHUFFLE_ON })
28 | })
29 |
30 | it('creates a TOGGLE_SHUFFLE_OFF when shuffle is turned off', () => {
31 | expect(actions.toggleShuffle(false)).toMatchObject({ type: types.TOGGLE_SHUFFLE_OFF })
32 | })
33 |
34 | })
--------------------------------------------------------------------------------
/src/redux/actions/actionTypes.js:
--------------------------------------------------------------------------------
1 | // Load list of artists
2 | export const LOAD_ARTISTS = "LOAD_ARTISTS"
3 | export const LOAD_ARTISTS_INDEX_SUCCESS = "LOAD_ARTISTS_INDEX_SUCCESS"
4 | // Load single artist
5 | export const LOAD_ONE_ARTIST_SUCCESS = "LOAD_ONE_ARTIST_SUCCESS"
6 | export const LOAD_SONGS_OF_ONE_ARTIST_SUCCESS = "LOAD_SONGS_OF_ONE_ARTIST_SUCCESS"
7 | // Load playlists
8 | export const LOAD_PLAYLISTS = "LOAD_PLAYLISTS"
9 | export const LOAD_PLAYLISTS_SUCCESS = "LOAD_PLAYLISTS_SUCCESS"
10 | export const LOAD_SINGLE_PLAYLIST_SUCCESS = "LOAD_SINGLE_PLAYLIST_SUCCESS"
11 | // Edit playlist
12 | export const ADD_SONGS_TO_PLAYLIST_RESULT = "ADD_SONGS_TO_PLAYLIST_RESULT"
13 | export const REMOVE_SONGS_FROM_PLAYLIST_RESULT = "REMOVE_SONGS_FROM_PLAYLIST_RESULT"
14 | export const DELETE_PLAYLIST_RESULT = "DELETE_PLAYLIST_RESULT"
15 | export const EDIT_PLAYLIST_RESULT = "EDIT_PLAYLIST_RESULT"
16 | // Songs management
17 | export const ADD_SONGS_TO_QUEUE = "ADD_SONGS_TO_QUEUE"
18 | export const PUT_SONGS_IN_QUEUE = "PUT_SONGS_IN_QUEUE"
19 | export const PLAY_NEXT_SONG = "PLAY_NEXT_SONG"
20 | export const PLAY_PREVIOUS_SONG = "PLAY_PREVIOUS_SONG"
21 | export const STAR_SONG_RESULT = "STAR_SONG_RESULT"
22 | export const CLEAR_QUEUE = "CLEAR_QUEUE"
23 | export const REMOVE_SONGS_FROM_QUEUE = "REMOVE_SONGS_FROM_QUEUE"
24 | export const SEEK_TO_SONG_IN_QUEUE = "SEEK_TO_SONG_IN_QUEUE"
25 | export const TOGGLE_SHUFFLE_ON = "TOGGLE_SHUFFLE_ON"
26 | export const TOGGLE_SHUFFLE_OFF = "TOGGLE_SHUFFLE_OFF"
27 | // Authentication
28 | export const LAZY_LOGIN_IGNORE = "LAZY_LOGIN_IGNORE"
29 | export const LOGIN_USER_REQUEST = "LOGIN_USER_REQUEST"
30 | export const LOGIN_USER_FAILURE = "LOGIN_USER_FAILURE"
31 | export const LOGIN_USER_SUCCESS = "LOGIN_USER_SUCCESS"
32 | export const LOGOUT_USER = "LOGOUT_USER"
33 | // API calls
34 | export const BEGIN_ASYNC_OPERATION = "BEGIN_ASYNC_OPERATION"
35 | export const END_ASYNC_OPERATION = "END_ASYNC_OPERATION"
36 | // Search
37 | export const SEARCH_RESULT = "SEARCH_RESULT"
38 | // Albums
39 | export const LOAD_ALBUM_SUCCESS = "LOAD_ONE_ALBUM_SUCCESS"
40 | export const LOAD_ALBUMS_LIST_SUCCESS = "LOAD_ALBUMS_LIST_SUCCESS"
41 | export const STAR_ALBUM_RESULT = "STAR_ALBUM_RESULT"
42 | // Favourites
43 | export const LOAD_FAVOURITES_RESULT = "LOAD_FAVOURITES_RESULT"
44 | // Genres
45 | export const LOAD_SONGS_OF_GENRE_SUCCESS = "LOAD_SONGS_OF_GENRE_SUCCESS"
--------------------------------------------------------------------------------
/src/redux/actions/alertsActions.js:
--------------------------------------------------------------------------------
1 |
2 | export const ALERT_TYPE_SUCCESS = "ALERT_TYPE_SUCCESS"
3 | export const ALERT_TYPE_WARNING = "ALERT_TYPE_WARNING"
4 | export const ALERT_TYPE_ERROR = "ALERT_TYPE_ERROR"
5 |
6 | /* Functions NOT meant to dispatch actions, only to build the alerts */
7 | export function alertSuccessObject(message) {
8 | return { alert: { type: ALERT_TYPE_SUCCESS, message: message } }
9 | }
10 |
11 | export function alertWarningObject(message) {
12 | return { alert: { type: ALERT_TYPE_WARNING, message : message } }
13 | }
14 |
15 | export function alertErrorObject(message) {
16 | return { alert: { type: ALERT_TYPE_ERROR, message : message } }
17 | }
18 |
--------------------------------------------------------------------------------
/src/redux/actions/apiStatusActions.js:
--------------------------------------------------------------------------------
1 | import * as types from "./actionTypes"
2 | import * as alerts from "./alertsActions"
3 |
4 | export function beginAsyncTask() {
5 | return { type: types.BEGIN_ASYNC_OPERATION }
6 | }
7 |
8 | export function asyncTaskSuccess(message) {
9 | return message ? { type: types.END_ASYNC_OPERATION, ...alerts.alertSuccessObject(message) } : { type: types.END_ASYNC_OPERATION }
10 | }
11 |
12 | export function asyncTaskWarning(message) {
13 | return message ? { type: types.END_ASYNC_OPERATION, ...alerts.alertWarningObject(message) } : { type: types.END_ASYNC_OPERATION }
14 | }
15 |
16 | export function asyncTaskError(message) {
17 | return message ? { type: types.END_ASYNC_OPERATION, ...alerts.alertErrorObject(message) } : { type: types.END_ASYNC_OPERATION }
18 | }
19 |
--------------------------------------------------------------------------------
/src/redux/actions/artistsActions.js:
--------------------------------------------------------------------------------
1 | import * as types from "./actionTypes"
2 | import subsonic from "../../api/subsonicApi"
3 | import { beginAsyncTask, asyncTaskSuccess, asyncTaskError } from "./apiStatusActions"
4 |
5 | /* Load multiple artists */
6 | export function loadArtistsSuccess(artists) {
7 | return { type: types.LOAD_ARTISTS_INDEX_SUCCESS, payload: {artistsIndex: artists} }
8 | }
9 |
10 | export function loadArtists() {
11 | return async (dispatch) => {
12 | dispatch(beginAsyncTask())
13 | try {
14 | const artists = await subsonic.getArtists()
15 | dispatch(loadArtistsSuccess(artists))
16 | dispatch(asyncTaskSuccess())
17 | }
18 | catch(error) {
19 | console.error(error)
20 | dispatch(asyncTaskError("Could not load artists"))
21 | }
22 | }
23 | }
24 |
25 | /* Load songs of just one artist */
26 | export const loadSongsOfArtistSuccess = songs => ({type : types.LOAD_SONGS_OF_ONE_ARTIST_SUCCESS, payload: { songs : songs} })
27 |
28 | async function loadSongsOfArtist(artist) {
29 | // Create a "big" promise to fetch all albums and return
30 | // just one result with all the content
31 | return Promise.all( artist.album.map(album => subsonic.getAlbum(album.id)) )
32 | .then(response => {
33 | // Combine all the songs in one single result
34 | return response.reduce( (accum, current) => accum.concat(current.song), [] )
35 | })
36 | }
37 |
38 | /* Load information of one artist along with its songs */
39 |
40 | export const loadOneArtistSuccess = artist => ({type: types.LOAD_ONE_ARTIST_SUCCESS, payload : {artist: artist}})
41 |
42 | export function loadOneArtist(artistId) {
43 | return async (dispatch) => {
44 | dispatch(beginAsyncTask())
45 | try {
46 | const artist = await subsonic.getArtist(artistId)
47 | dispatch(loadOneArtistSuccess(artist))
48 | dispatch(asyncTaskSuccess())
49 | // Now load all its songs
50 | dispatch(beginAsyncTask())
51 | const songs = await loadSongsOfArtist(artist)
52 | dispatch(loadSongsOfArtistSuccess(songs))
53 | dispatch(asyncTaskSuccess())
54 | }
55 | catch(error) {
56 | console.error(error)
57 | dispatch(asyncTaskError("Unable to load artist"))
58 | }
59 | }
60 | }
--------------------------------------------------------------------------------
/src/redux/actions/authActions.js:
--------------------------------------------------------------------------------
1 | import * as types from "./actionTypes"
2 | import subsonic from "../../api/subsonicApi"
3 |
4 | export function loginUserRequest() {
5 | return { type: types.LOGIN_USER_REQUEST }
6 | }
7 |
8 | export function loginUserSuccess(host, username, enc) {
9 | localStorage.setItem('host', host)
10 | localStorage.setItem('username', username)
11 | localStorage.setItem('enc', enc)
12 | // Update subsonic API
13 | subsonic.setConfig(host, username, enc, false)
14 | // Log in
15 | return {
16 | type: types.LOGIN_USER_SUCCESS,
17 | }
18 | }
19 |
20 | export function loginUserFailure(error) {
21 | localStorage.removeItem('host')
22 | localStorage.removeItem('username')
23 | localStorage.removeItem('enc')
24 | return {
25 | type: types.LOGIN_USER_FAILURE,
26 | payload: {
27 | statusText: error.message
28 | }
29 | }
30 | }
31 |
32 | export function loginUser(host, username, password, encodePassword = true) {
33 | return async (dispatch) => {
34 | dispatch(loginUserRequest())
35 | // Sanitize host removing trialing /
36 | host = host.replace(/\/$/, '')
37 | // Perform login
38 | try {
39 | const success = await subsonic.login(host, username, password, encodePassword)
40 | if( success ) {
41 | const passwordToStore = encodePassword ? subsonic.getEncodedPassword(password) : password
42 | dispatch(loginUserSuccess(host, username, passwordToStore))
43 | }
44 | else {
45 | dispatch(loginUserFailure({statusText:"Authentication failed"}))
46 | }
47 | }
48 | catch(err) {
49 | console.error(err)
50 | dispatch(loginUserFailure(err))
51 | }
52 |
53 | }
54 | }
55 |
56 | export function lazyLoginUser() {
57 | // Check if we have credentials stored and try to log in existing user
58 | const host = localStorage.getItem('host')
59 | const username = localStorage.getItem('username')
60 | const enc = localStorage.getItem('enc')
61 | if( host && username && enc ) {
62 | return loginUser(host, username, enc, false)
63 | }
64 | return {type: types.LAZY_LOGIN_IGNORE}
65 | }
66 |
67 | export function logout() {
68 | localStorage.clear()
69 | return { type: types.LOGOUT_USER }
70 | }
--------------------------------------------------------------------------------
/src/redux/actions/favouritesActions.js:
--------------------------------------------------------------------------------
1 | import * as types from "./actionTypes"
2 | import subsonic from "../../api/subsonicApi"
3 | import { beginAsyncTask, asyncTaskSuccess, asyncTaskError, asyncTaskWarning } from "./apiStatusActions"
4 |
5 | export function favouriteSongsLoaded(favSongs) {
6 | return {type: types.LOAD_FAVOURITES_RESULT, payload: {songs : favSongs} }
7 | }
8 |
9 | export function loadFavouriteSongs() {
10 | return async (dispatch) => {
11 | dispatch(beginAsyncTask())
12 | try {
13 | const favourites = await subsonic.getStarred()
14 | const favSongs = favourites["song"] || []
15 | // Set to state
16 | dispatch(favouriteSongsLoaded(favSongs))
17 | dispatch(asyncTaskSuccess())
18 | }
19 | catch(error) {
20 | console.error(error)
21 | dispatch(asyncTaskError("Could not retrieve favourites"))
22 | }
23 | }
24 | }
25 |
26 | export function starredSongModified(songIds, setStarred) {
27 | return { type: types.STAR_SONG_RESULT, payload : { songIds: songIds, starred : setStarred } }
28 | }
29 |
30 | export function setStarOnSongs(songs, setStarred) {
31 | return async (dispatch) => {
32 | dispatch(beginAsyncTask())
33 | // We need to remove the songs that are already starred before calling the API.
34 | // As song.starred is a string with the date the song was starred,
35 | // we need to "toggle" it to convert to a boolean
36 | const songIds = songs.filter(song => !song.starred === setStarred).map(song => song.id)
37 | if( songIds.length === 0 ) {
38 | dispatch(asyncTaskWarning("All songs are already in favourites"))
39 | }
40 | else {
41 | try {
42 | let result = null
43 | let message = null
44 | if( setStarred ) {
45 | result = await subsonic.star(songIds)
46 | message = result ? "Songs added to favs!" : "Unable to add songs"
47 | setStarred = new Date().toISOString()
48 | }
49 | else {
50 | result = await subsonic.unstar(songIds)
51 | message = result ? "Songs removed from favs!" : "Unable to remove songs"
52 | }
53 | // Dispatch result
54 | if( result ) {
55 | dispatch(starredSongModified(songIds, setStarred))
56 | dispatch(asyncTaskSuccess(message))
57 | }
58 | else {
59 | dispatch(asyncTaskError(message))
60 | }
61 | }
62 | catch(error) {
63 | console.error(error)
64 | dispatch(asyncTaskError(`Could not change ${songIds.length} songs`))
65 | }
66 | }
67 |
68 | }
69 | }
--------------------------------------------------------------------------------
/src/redux/actions/genresActions.js:
--------------------------------------------------------------------------------
1 | import * as types from "./actionTypes"
2 | import subsonic from "../../api/subsonicApi"
3 | import { beginAsyncTask, asyncTaskSuccess, asyncTaskError } from "./apiStatusActions"
4 |
5 | async function performGenreRequests(genre) {
6 | // Load all the songs of this genre in chunks of 500 (max allowed by Subsonic API)
7 | let offsets = []
8 | let count = 0
9 | while( count < genre.songCount ) {
10 | offsets.push([count])
11 | count += 500
12 | }
13 | // Create a "big" promise to fetch all the songs and return
14 | // just one result with all the content
15 | return Promise.all( offsets.map(offset => subsonic.getSongsByGenre(genre.value, offset)) )
16 | .then(response => {
17 | // Combine all the songs in one single result
18 | return response.reduce( (accum, current) => accum.concat(current), [] )
19 | })
20 | }
21 |
22 | export const loadSongsOfGenreSuccess = songs => ({type : types.LOAD_SONGS_OF_GENRE_SUCCESS, payload: { songs : songs} })
23 |
24 | export function loadSongsOfGenre(genre) {
25 | return async (dispatch) => {
26 | dispatch(beginAsyncTask())
27 | try {
28 | const songs = await performGenreRequests(genre)
29 | dispatch(loadSongsOfGenreSuccess(songs))
30 | dispatch(asyncTaskSuccess())
31 | }
32 | catch(error) {
33 | console.error(error)
34 | dispatch(asyncTaskError("Unable to load genre"))
35 | }
36 | }
37 | }
--------------------------------------------------------------------------------
/src/redux/actions/searchActions.js:
--------------------------------------------------------------------------------
1 | import * as types from "./actionTypes"
2 | import subsonic from "../../api/subsonicApi"
3 | import { beginAsyncTask, asyncTaskSuccess, asyncTaskError } from "./apiStatusActions"
4 |
5 | export function searchAction(result) {
6 | return { type: types.SEARCH_RESULT, payload:result }
7 | }
8 |
9 | export function search(query) {
10 | return async (dispatch) => {
11 | dispatch(beginAsyncTask())
12 | try {
13 | const result = await subsonic.search(query)
14 | dispatch(searchAction(result))
15 | dispatch(asyncTaskSuccess())
16 | }
17 | catch(error) {
18 | console.error(error)
19 | dispatch(asyncTaskError("Unable to perform search"))
20 | }
21 | }
22 | }
--------------------------------------------------------------------------------
/src/redux/actions/songsActions.js:
--------------------------------------------------------------------------------
1 | import * as types from "./actionTypes"
2 | import * as alerts from "./alertsActions"
3 | import * as settings from "../../utils/settings"
4 |
5 | export function addSongsToQueue(songs) {
6 | return { type: types.ADD_SONGS_TO_QUEUE, payload: {songs}, ...alerts.alertSuccessObject(`${songs.length} songs added to the queue!`) }
7 | }
8 |
9 | export function putSongsInQueue(songs, songToPlay=null) {
10 | return { type: types.PUT_SONGS_IN_QUEUE, payload: {songs, songToPlay} }
11 | }
12 |
13 | export function seekToSongInQueue(song) {
14 | return { type: types.SEEK_TO_SONG_IN_QUEUE, payload: {song} }
15 | }
16 |
17 | export function playNextSong() {
18 | return { type: types.PLAY_NEXT_SONG }
19 | }
20 |
21 | export function playPreviousSong() {
22 | return { type: types.PLAY_PREVIOUS_SONG }
23 | }
24 |
25 | export function clearQueue() {
26 | return { type: types.CLEAR_QUEUE }
27 | }
28 |
29 | export function removeSongsFromQueue(songs) {
30 | return { type: types.REMOVE_SONGS_FROM_QUEUE, payload: {songs} }
31 | }
32 |
33 | export function toggleShuffle(turnOn) {
34 | settings.setShuffle(turnOn)
35 | return { type: turnOn ? types.TOGGLE_SHUFFLE_ON : types.TOGGLE_SHUFFLE_OFF }
36 | }
37 |
--------------------------------------------------------------------------------
/src/redux/configureStore.dev.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware, compose } from "redux"
2 | import rootReducer from "./reducers"
3 | import reduxImmutableStateInvariant from "redux-immutable-state-invariant"
4 | import thunk from "redux-thunk"
5 |
6 | /**
7 | * Logs all actions and states after they are dispatched.
8 | */
9 | const logger = store => next => action => {
10 | console.group(action.type)
11 | console.info('dispatching', action)
12 | let result = next(action)
13 | console.log('next state', store.getState())
14 | console.groupEnd()
15 | return result
16 | }
17 |
18 | export default function configureStore(initialState) {
19 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose // add support for Redux dev tools
20 |
21 | return createStore(
22 | rootReducer,
23 | initialState,
24 | composeEnhancers(applyMiddleware(thunk, logger, reduxImmutableStateInvariant()))
25 | )
26 | }
--------------------------------------------------------------------------------
/src/redux/configureStore.js:
--------------------------------------------------------------------------------
1 | // Use CommonJS require below so we can dynamically import during build-time.
2 | if (process.env.NODE_ENV === "production" || process.env.NODE_ENV === "test") {
3 | module.exports = require("./configureStore.prod");
4 | }
5 | else {
6 | module.exports = require("./configureStore.dev");
7 | }
--------------------------------------------------------------------------------
/src/redux/configureStore.prod.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware } from "redux"
2 | import rootReducer from "./reducers"
3 | import thunk from "redux-thunk"
4 |
5 | export default function configureStore(initialState) {
6 | return createStore(rootReducer, initialState, applyMiddleware(thunk))
7 | }
8 |
--------------------------------------------------------------------------------
/src/redux/reducers/_tests_/alertsReducer.test.js:
--------------------------------------------------------------------------------
1 | import alertsReducer from "../alertsReducer"
2 |
3 | describe('alerts reducer', () => {
4 |
5 | it('should no nothing when an action does not contain an alert', () => {
6 | // Define initial state
7 | const initialState = {
8 | 'id' : '1',
9 | 'type' : 'ALERT_TYPE',
10 | 'message' : 'message'
11 | }
12 | // Expect it to return when no specific action is triggered
13 | expect( alertsReducer(initialState, {}) ).toEqual(initialState)
14 | })
15 |
16 | it('should create a new alert when required by the action', () => {
17 | // Define initial state
18 | const initialState = {
19 | 'id' : '1',
20 | 'type' : 'ALERT_TYPE',
21 | 'message' : 'message'
22 | }
23 | const newAlert = { alert : { type : 'NEW_TYPE', message : 'new message' } }
24 | // Expect it to return when no specific action is triggered
25 | expect( alertsReducer(initialState, newAlert) ).toMatchObject(newAlert.alert)
26 | })
27 |
28 | })
--------------------------------------------------------------------------------
/src/redux/reducers/_tests_/artistsReducer.test.js:
--------------------------------------------------------------------------------
1 | import artistsReducer from "../artistsReducer"
2 | import { logout } from "../../actions/authActions"
3 | import * as actions from "../../actions/artistsActions"
4 |
5 | describe('artists reducer', () => {
6 |
7 | it('should return the initial state', () => {
8 | // Define initial state
9 | const initialState = { byIndex : [], byId : {} }
10 | // Expect it to return when no specific action is triggered
11 | expect( artistsReducer(initialState, {}) ).toEqual(initialState)
12 | })
13 |
14 | it('should clear the artists on logout', () => {
15 | // Define initial state
16 | const initialState = {
17 | byIndex : [
18 | {
19 | name : 'A',
20 | artist : [ {id : '1'} ]
21 | }
22 | ],
23 | byId : {
24 | '1' : {id : '1'}
25 | }
26 | }
27 | // Expect it to return no albums when logout is triggered
28 | expect( artistsReducer(initialState, logout()) ).toEqual({ byIndex : [], byId : {} })
29 | })
30 |
31 | it('should put all the artists by index loaded', () => {
32 | // Define initial state
33 | const initialState = { byIndex : [], byId : {} }
34 | // Define loaded artists' index
35 | const loadedArtists = [
36 | {
37 | name : 'A',
38 | artist : [ {id : '1'} ]
39 | }
40 | ]
41 | // Expect it to replace the existing index with the new one
42 | expect(
43 | artistsReducer(initialState, actions.loadArtistsSuccess(loadedArtists))
44 | )
45 | .toEqual({
46 | byIndex : loadedArtists,
47 | byId : initialState.byId
48 | })
49 | })
50 |
51 | it('should put one normalized artist when loaded', () => {
52 | // Define initial state
53 | const initialState = {
54 | byIndex : [
55 | {
56 | name : 'A',
57 | artist : [ {id : '1'} ]
58 | }
59 | ],
60 | byId : {
61 | '1' : {id : '1'}
62 | }
63 | }
64 | // Define loaded artists' index
65 | const newArtist = {
66 | id : '2',
67 | album : [
68 | { 'id' : 'album1' },
69 | { 'id' : 'album2' },
70 | ]
71 | }
72 | const newState = artistsReducer(initialState, actions.loadOneArtistSuccess(newArtist))
73 | // Expect it to:
74 | // Keep the index as before
75 | expect( newState.byIndex ) .toEqual(initialState.byIndex)
76 | // As only the details of one artist is displayed at a time, we can clear the existing
77 | // artist.byId with the new one
78 | expect( newState.byId ) .toEqual({
79 | '2' : {
80 | id : '2',
81 | album : ['album1', 'album2']
82 | }
83 | })
84 | })
85 |
86 | })
--------------------------------------------------------------------------------
/src/redux/reducers/_tests_/asyncTasksReducer.test.js:
--------------------------------------------------------------------------------
1 | import asyncTasksReducer from "../asyncTasksReducer"
2 | import * as apiStatusActions from "../../actions/apiStatusActions"
3 |
4 | describe('async tasks reducer', () => {
5 |
6 | it('should return the initial state', () => {
7 | // Define initial state
8 | const initialState = 5
9 | // Expect it to return when no specific action is triggered
10 | expect( asyncTasksReducer(initialState, {}) ).toEqual(initialState)
11 | })
12 |
13 | it('should count +1 when an async task is started', () => {
14 | // Define initial state and begin an async task
15 | const initialState = 4
16 | expect( asyncTasksReducer(initialState, apiStatusActions.beginAsyncTask() ) ).toEqual(4+1)
17 | })
18 |
19 | it('should count -1 when an async task ends in success', () => {
20 | // Define initial state and end an async task
21 | const initialState = 4
22 | expect( asyncTasksReducer(initialState, apiStatusActions.asyncTaskSuccess() ) ).toEqual(4-1)
23 | })
24 |
25 | it('should count -1 when an async task ends in warning', () => {
26 | // Define initial state and end an async task
27 | const initialState = 4
28 | expect( asyncTasksReducer(initialState, apiStatusActions.asyncTaskWarning() ) ).toEqual(4-1)
29 | })
30 |
31 | it('should count -1 when an async task ends in error', () => {
32 | // Define initial state and end an async task
33 | const initialState = 4
34 | expect( asyncTasksReducer(initialState, apiStatusActions.asyncTaskError() ) ).toEqual(4-1)
35 | })
36 |
37 | })
--------------------------------------------------------------------------------
/src/redux/reducers/_tests_/authReducer.test.js:
--------------------------------------------------------------------------------
1 | import authReducer from "../authReducer"
2 | import * as actions from "../../actions/authActions"
3 |
4 | describe('async tasks reducer', () => {
5 |
6 | it('should return the initial state', () => {
7 | // Define initial state
8 | const initialState = { isAuthenticated: false, isAuthenticating: true, statusText: null }
9 | // Expect it to return when no specific action is triggered
10 | expect( authReducer(initialState, {}) ).toEqual(initialState)
11 | })
12 |
13 | it('should set status as authentication in progress', () => {
14 | const initialState = { isAuthenticated: false, isAuthenticating: true, statusText: null }
15 | expect( authReducer(initialState, actions.loginUserRequest() ) ).toEqual({
16 | isAuthenticated : false,
17 | isAuthenticating : true,
18 | statusText : null
19 | })
20 | })
21 |
22 | it('should set status as authentication was successful', () => {
23 | const initialState = { isAuthenticated: false, isAuthenticating: true, statusText: null }
24 | expect(
25 | authReducer(initialState, actions.loginUserSuccess("host", "username", "enc:pass") )
26 | ).toMatchObject({
27 | isAuthenticated : true,
28 | isAuthenticating : false,
29 | })
30 | })
31 |
32 | it('should set status as authentication was unsuccessful with an error to display', () => {
33 | const initialState = { isAuthenticated: false, isAuthenticating: true, statusText: null }
34 | const newState = authReducer(initialState, actions.loginUserFailure(new Error('ERROR')))
35 | expect( newState.isAuthenticated ).toEqual(false)
36 | expect( newState.isAuthenticating ).toEqual(false)
37 | expect( newState.statusText ).toEqual('ERROR')
38 | })
39 |
40 | it('should clear auth details on logout', () => {
41 | const initialState = { isAuthenticated: true, isAuthenticating: true, statusText: null }
42 | const newState = authReducer(initialState, actions.logout() )
43 | expect( newState.isAuthenticated ).toEqual(false)
44 | })
45 |
46 | it('should set authenticated as false when lazy login failed', () => {
47 | const initialState = { isAuthenticated: true, isAuthenticating: true, statusText: null }
48 | const newState = authReducer(initialState, actions.lazyLoginUser() )
49 | expect( newState.isAuthenticating ).toEqual(false)
50 | })
51 |
52 | })
--------------------------------------------------------------------------------
/src/redux/reducers/_tests_/favouritesReducer.test.js:
--------------------------------------------------------------------------------
1 | import favouritesReducer from "../favouritesReducer"
2 | import * as actions from "../../actions/favouritesActions"
3 | import { logout } from "../../actions/authActions"
4 |
5 | describe('async tasks reducer', () => {
6 |
7 | it('should return the initial state', () => {
8 | // Define initial state
9 | const initialState = []
10 | // Expect it to return when no specific action is triggered
11 | expect( favouritesReducer(initialState, {}) ).toEqual(initialState)
12 | })
13 |
14 | it('should clear the favourites on logout', () => {
15 | const initialState = [ '1', '2', '3' ]
16 | expect( favouritesReducer(initialState, logout() ) ).toEqual([])
17 | })
18 |
19 | it('should put the favourites when loaded', () => {
20 | const initialState = [ '1', '2', '3' ]
21 | const favSongs = [ { id: '4'}, { id : '5'} ]
22 | expect( favouritesReducer(initialState, actions.favouriteSongsLoaded(favSongs) ) ).toEqual(['4', '5'])
23 | })
24 |
25 | it('should add the new songs to favourites when starred', () => {
26 | const initialState = [ '1', '2', '3' ]
27 | expect(
28 | favouritesReducer(initialState, actions.starredSongModified(['4', '5'], new Date().toISOString()) )
29 | ).toEqual([ '1', '2', '3', '4', '5'])
30 | })
31 |
32 | it('should remove the songs from favourites when unstarred', () => {
33 | const initialState = [ '1', '2', '3' ]
34 | expect(
35 | favouritesReducer(initialState, actions.starredSongModified(['2'], false) )
36 | ).toEqual([ '1', '3'])
37 | })
38 |
39 | })
--------------------------------------------------------------------------------
/src/redux/reducers/albumsReducer.js:
--------------------------------------------------------------------------------
1 | import * as types from "../actions/actionTypes"
2 | import initialState from "./initialState"
3 | import {createReducer} from '../../utils/redux.js'
4 |
5 | export default createReducer(initialState.albums, {
6 | [types.LOAD_ALBUM_SUCCESS]: (state, payload) => {
7 | const album = payload.album
8 | album.song = album.song.map(song => song.id)
9 | return {
10 | ...state,
11 | byId : {
12 | ...state.byId,
13 | [album.id] : album
14 | }
15 | }
16 | },
17 | [types.LOAD_ALBUMS_LIST_SUCCESS]: (state, payload) => {
18 | const albums = payload.albums.reduce( (accum, curr) => ({
19 | [curr.id] : curr, ...accum
20 | }), {})
21 | return {
22 | ...state,
23 | byId : albums
24 | }
25 | },
26 | [types.LOAD_ONE_ARTIST_SUCCESS]: (state, payload) => {
27 | // Normalize the albums of the artist to just contain the IDs
28 | const artists_albums_by_id = {}
29 | payload.artist.album.forEach(album => {
30 | artists_albums_by_id[album.id] = album
31 | })
32 | return {
33 | ...state,
34 | byId : artists_albums_by_id
35 | }
36 | },
37 | [types.LOAD_SONGS_OF_ONE_ARTIST_SUCCESS]: (state, payload) => {
38 | // Look for albums with songs present in this payload and
39 | // put its corresponding IDs
40 | let newByIdState = {...state.byId}
41 | payload.songs.forEach(song => {
42 | const thisAlbum = newByIdState[song.albumId]
43 | if( thisAlbum ){
44 | newByIdState = {
45 | ...newByIdState,
46 | [song.albumId] : {
47 | ...thisAlbum,
48 | song : thisAlbum.song ? thisAlbum.song.concat(song.id) : [song.id]
49 | }
50 | }
51 | }
52 | })
53 | return {
54 | ...state,
55 | byId : newByIdState
56 | }
57 | },
58 | [types.STAR_ALBUM_RESULT]: (state, payload) => {
59 | let newAlbumsById = {}
60 | payload.albumIds.forEach(albumId => {
61 | newAlbumsById[albumId] = {
62 | ...state.byId[albumId],
63 | starred: payload.starred
64 | }
65 | })
66 | return {
67 | ...state,
68 | byId: {
69 | ...state.byId,
70 | ...newAlbumsById,
71 | }
72 | }
73 | },
74 | [types.LOGOUT_USER]: (state, payload) => initialState.albums
75 | })
--------------------------------------------------------------------------------
/src/redux/reducers/alertsReducer.js:
--------------------------------------------------------------------------------
1 |
2 | import initialState from "./initialState"
3 |
4 | function build_last_operation_result(type, message) {
5 | return { id: Date.now(), type:type, message:message }
6 | }
7 |
8 | function does_action_contain_alert(action) {
9 | return action.alert && action.alert.type && action.alert.message
10 | }
11 |
12 | /*
13 | * Usually, actions are dispatched with three keys:
14 | * type, payload, alert
15 | * Function "createReducer" in utils/redux.js just takes the "payload" object and passes it to the
16 | * body of the reducer to map it to its appropiate function.
17 | * This reducer doesnt use "createReducer" because it doesn't care about the type
18 | * of the action, it just looks for the "alert" key and if it is present, then that means
19 | * the action meant to display an alert notification and it is displayed.
20 | */
21 | export default (state = initialState.alert, action) => {
22 | // Check if a valid "alert" object is found in this action
23 | return does_action_contain_alert(action)
24 | ? build_last_operation_result(action.alert.type, action.alert.message)
25 | : state
26 | }
--------------------------------------------------------------------------------
/src/redux/reducers/artistsReducer.js:
--------------------------------------------------------------------------------
1 | import * as types from "../actions/actionTypes"
2 | import initialState from "./initialState"
3 | import {createReducer} from '../../utils/redux.js'
4 |
5 | export default createReducer(initialState.artists, {
6 | [types.LOAD_ARTISTS_INDEX_SUCCESS]: (state, payload) => {
7 | return {
8 | ...state,
9 | byIndex : payload.artistsIndex
10 | }
11 | },
12 | [types.LOAD_ONE_ARTIST_SUCCESS]: (state, payload) => {
13 | // Normalize the albums of the artist to just contain the IDs
14 | const normalized_albums = payload.artist.album.map(album => album.id)
15 | // As only the details of one artist is displayed at a time
16 | // we can replace the existing content of byId with the new artist
17 | return {
18 | ...state,
19 | byId : {
20 | [payload.artist.id] : {
21 | ...payload.artist,
22 | album : normalized_albums
23 | }
24 | }
25 | }
26 | },
27 | [types.LOGOUT_USER]: (state, payload) => initialState.artists,
28 | })
--------------------------------------------------------------------------------
/src/redux/reducers/asyncTasksReducer.js:
--------------------------------------------------------------------------------
1 |
2 | import * as types from "../actions/actionTypes"
3 | import initialState from "./initialState"
4 | import {createReducer} from '../../utils/redux.js'
5 |
6 | export default createReducer(initialState.asyncTasksInProgress, {
7 | [types.BEGIN_ASYNC_OPERATION]: (state, payload) => state + 1,
8 | [types.END_ASYNC_OPERATION]: (state, payload) => state - 1
9 | })
--------------------------------------------------------------------------------
/src/redux/reducers/authReducer.js:
--------------------------------------------------------------------------------
1 | import * as types from "../actions/actionTypes"
2 | import initialState from "./initialState"
3 | import {createReducer} from '../../utils/redux.js'
4 |
5 | export default createReducer(initialState.auth, {
6 | [types.LOGIN_USER_REQUEST]: (state, payload) => {
7 | return Object.assign({}, state, {
8 | 'isAuthenticating': true,
9 | 'statusText': null
10 | })
11 | },
12 | [types.LOGIN_USER_SUCCESS]: (state, payload) => {
13 | return Object.assign({}, state, {
14 | 'isAuthenticating': false,
15 | 'isAuthenticated': true,
16 | 'statusText': 'You have been successfully logged in.'
17 | })
18 |
19 | },
20 | [types.LOGIN_USER_FAILURE]: (state, payload) => {
21 | return Object.assign({}, state, {
22 | 'isAuthenticating': false,
23 | 'isAuthenticated': false,
24 | 'statusText': payload.statusText
25 | })
26 | },
27 | [types.LOGOUT_USER]: (state, payload) => {
28 | return Object.assign({}, state, {
29 | 'isAuthenticated': false,
30 | 'statusText': 'You have been successfully logged out.'
31 | })
32 | },
33 | [types.LAZY_LOGIN_IGNORE]: (state, payload) => {
34 | return Object.assign({}, state, {
35 | 'isAuthenticating': false,
36 | })
37 | }
38 | })
--------------------------------------------------------------------------------
/src/redux/reducers/favouritesReducer.js:
--------------------------------------------------------------------------------
1 | import * as types from "../actions/actionTypes"
2 | import initialState from "./initialState"
3 | import {createReducer} from '../../utils/redux.js'
4 |
5 | export default createReducer(initialState.favourites, {
6 | [types.LOAD_FAVOURITES_RESULT] : (state, payload) => payload.songs.map(song => song.id),
7 | [types.STAR_SONG_RESULT] : (state, payload) => {
8 | let newSongs = null
9 | // Concatenate the faved songs (in a new array) if a new song was added
10 | if( payload.starred ) {
11 | newSongs = state.concat( payload.songIds )
12 | }
13 | // Or create a new array without the deleted songs as the songs' array
14 | else {
15 | newSongs = state.filter(s => !payload.songIds.includes(s))
16 | }
17 | return newSongs
18 | },
19 | [types.LOGOUT_USER]: (state, payload) => initialState.favourites
20 | })
--------------------------------------------------------------------------------
/src/redux/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from "redux"
2 | import asyncTasksInProgress from "./asyncTasksReducer"
3 | import artists from "./artistsReducer"
4 | import playlists from "./playlistsReducer"
5 | import songs from "./songsReducer"
6 | import auth from "./authReducer"
7 | import search from "./searchReducer"
8 | import alert from "./alertsReducer"
9 | import albums from "./albumsReducer"
10 | import favourites from "./favouritesReducer"
11 | import musicPlayer from "./musicPlayerReducer"
12 |
13 | export default combineReducers({
14 | asyncTasksInProgress,
15 | artists,
16 | playlists,
17 | songs,
18 | auth,
19 | search,
20 | alert,
21 | albums,
22 | favourites,
23 | musicPlayer
24 | })
--------------------------------------------------------------------------------
/src/redux/reducers/initialState.js:
--------------------------------------------------------------------------------
1 | import * as settings from "../../utils/settings"
2 |
3 | export default {
4 | asyncTasksInProgress: 0,
5 | alert : {},
6 | artists : {
7 | byIndex : [],
8 | byId : {}
9 | },
10 | albums : {
11 | byId : {}
12 | },
13 | playlists : {
14 | byId : {}
15 | },
16 | favourites : [],
17 | songs : {
18 | byId : {}
19 | },
20 | musicPlayer : {
21 | queue : [],
22 | original : [],
23 | songsById : {},
24 | currentSongIndex : null,
25 | currentSongId : null,
26 | isShuffleOn: settings.getIsShuffleOn(),
27 | },
28 | auth : {
29 | isAuthenticated: false,
30 | isAuthenticating: true,
31 | statusText: null
32 | },
33 | search : {
34 | albums : [],
35 | artists : [],
36 | songs : [],
37 | songsById : {}
38 | }
39 | }
--------------------------------------------------------------------------------
/src/redux/reducers/searchReducer.js:
--------------------------------------------------------------------------------
1 | import * as types from "../actions/actionTypes"
2 | import initialState from "./initialState"
3 | import {createReducer, get_normalized_songs, set_starred_song_on_state} from '../../utils/redux.js'
4 |
5 | export default createReducer(initialState.search, {
6 | [types.SEARCH_RESULT]: (state, payload) => {
7 | return {
8 | 'albums': payload.album,
9 | 'artists': payload.artist,
10 | 'songs': payload.song ? payload.song.map(song => song.id) : [],
11 | 'songsById' : payload.song ? get_normalized_songs(payload.song) : {}
12 | }
13 | },
14 | [types.STAR_SONG_RESULT] : (state, payload) => {
15 | let newState = state
16 | // Toggle if it is found in the DB of songs
17 | const modifiedSongsInDB = payload.songIds.filter(id => state.songsById[id])
18 | modifiedSongsInDB.forEach( (id) => {
19 | newState = set_starred_song_on_state(newState, 'songsById', id, payload.starred)
20 | })
21 | return newState
22 | },
23 | [types.LOGOUT_USER]: (state, payload) => {
24 | return initialState.search
25 | }
26 | })
--------------------------------------------------------------------------------
/src/redux/reducers/songsReducer.js:
--------------------------------------------------------------------------------
1 | import * as types from "../actions/actionTypes"
2 | import initialState from "./initialState"
3 | import { createReducer, set_starred_song_on_state } from '../../utils/redux.js'
4 |
5 | function put_songs_in_store(state, songs, clearCurrentList = true) {
6 | // Transform the array of songs coming in the payload to a normalized object
7 | let normalized_songs = songs.reduce( (current,song) => ({...current, [song.id] : song }), {} )
8 | // Replace the current songs if "clearCurrentList" or append the new songs to
9 | // the existing list
10 | const newSongs = clearCurrentList
11 | ? normalized_songs
12 | : {...state.byId, ...normalized_songs}
13 | return { ...state, byId : newSongs }
14 | }
15 |
16 | export default createReducer(initialState.songs, {
17 | [types.STAR_SONG_RESULT] : (state, payload) => {
18 | let newState = state
19 | // Toggle if it is found in the DB of songs
20 | const modifiedSongsInDB = payload.songIds.filter(id => state.byId[id])
21 | modifiedSongsInDB.forEach( (id) => {
22 | newState = set_starred_song_on_state(newState, 'byId', id, payload.starred)
23 | })
24 | return newState
25 | },
26 | [types.LOAD_FAVOURITES_RESULT] : (state, payload) => put_songs_in_store(state, payload.songs, true),
27 | [types.LOAD_SONGS_OF_ONE_ARTIST_SUCCESS] : (state, payload) => put_songs_in_store(state, payload.songs, true),
28 | [types.LOAD_SONGS_OF_GENRE_SUCCESS] : (state, payload) => put_songs_in_store(state, payload.songs, true),
29 | [types.LOAD_ALBUM_SUCCESS] : (state, payload) => put_songs_in_store(state, payload.album.song, true),
30 | [types.LOAD_SINGLE_PLAYLIST_SUCCESS] : (state, payload) => put_songs_in_store(state, payload.playlist.entry, true),
31 | [types.LOGOUT_USER]: (state, payload) => initialState.songs
32 | })
--------------------------------------------------------------------------------
/src/redux/selectors/_tests_/albumSelectors.test.js:
--------------------------------------------------------------------------------
1 | import * as selectors from "../albumSelectors"
2 |
3 | describe('album selectors', () => {
4 |
5 | it('should filter the albums of an specific artist', () => {
6 | // All albums should go in the state
7 | const state = {
8 | albums : {
9 | byId : {
10 | '1' : {
11 | id : '1',
12 | artistId : 'nope'
13 | },
14 | '2' : {
15 | id : '2',
16 | artistId : 'artist1'
17 | }
18 | }
19 | }
20 | }
21 | // Artist Id should go in props
22 | const props = { artistId : 'artist1' }
23 | // Albums should have only album 2
24 | const albums = selectors.getAlbumsOfArtist(state, props)
25 | expect(albums).toEqual( [state.albums.byId['2']] )
26 | })
27 |
28 | it("should get a list of all albums", () => {
29 | // All albums should go in the state
30 | const state = {
31 | albums : {
32 | byId : {
33 | '1' : {
34 | id : '1',
35 | artistId : 'nope'
36 | },
37 | '2' : {
38 | id : '2',
39 | artistId : 'artist1'
40 | }
41 | }
42 | }
43 | }
44 | // all albums should be retrieved as an array
45 | const albums = selectors.albumsSelector(state)
46 | expect(albums).toEqual( [state.albums.byId['1'], state.albums.byId['2']] )
47 | })
48 |
49 |
50 | })
--------------------------------------------------------------------------------
/src/redux/selectors/_tests_/artistSelectors.test.js:
--------------------------------------------------------------------------------
1 | import * as selectors from "../artistSelectors"
2 |
3 | describe('artist selectors', () => {
4 |
5 | it('should flatten the aritst.byIndex object', () => {
6 | // All artists should go in the state
7 | const state = {
8 | artists : {
9 | byIndex : [
10 | { name : "#", artist : [{id : "1"}] },
11 | { name : "A", artist : [{id : "2"}, {id: "3"}] },
12 | ]
13 | }
14 | }
15 | // Albums should have only album 2
16 | const artists = selectors.getArtistsWithHeaders(state)
17 | expect(artists).toEqual([ {header:"#"}, {id:"1"}, {header:"A"}, {id:"2"}, {id:"3"} ])
18 | })
19 |
20 |
21 | })
--------------------------------------------------------------------------------
/src/redux/selectors/_tests_/musicPlayerSelector.test.js:
--------------------------------------------------------------------------------
1 | import * as selectors from "../musicPlayerSelector"
2 |
3 | describe('music player selectors', () => {
4 |
5 | it('should get the metadata of the song currently playing', () => {
6 | // Song playing is song '2' which is at position 1 of the queue
7 | const state = {
8 | musicPlayer : {
9 | queue : ["1", "2"],
10 | songsById : {
11 | "1" : { id : '1' },
12 | "2" : {
13 | id : '2',
14 | name : 'name 2'
15 | }
16 | },
17 | currentSongIndex : 1,
18 | currentSongId : '2',
19 | }
20 | }
21 | // Should return song '2'
22 | const currentSongPlaying = selectors.getSongCurrentlyPlayingSelector(state)
23 | expect(currentSongPlaying).toEqual( state.musicPlayer.songsById['2'] )
24 | })
25 |
26 | it('should get the details of the songs pending in the queue', () => {
27 | // Song playing is song '2' which is at position 1 of the queue
28 | const state = {
29 | musicPlayer : {
30 | queue : ["1", "2", "3"],
31 | songsById : {
32 | "1" : { id : '1' },
33 | "2" : { id : '2' },
34 | "3" : { id : '3' },
35 | },
36 | currentSongIndex : 1,
37 | currentSongId : '2',
38 | }
39 | }
40 | // Should return songs '2' (the one currently playing) and '3'
41 | const pendingSongs = selectors.getSongsInQueueSelector(state)
42 | expect(pendingSongs).toEqual( [state.musicPlayer.songsById['2'],state.musicPlayer.songsById['3']] )
43 | })
44 |
45 |
46 | })
--------------------------------------------------------------------------------
/src/redux/selectors/_tests_/searchSelectors.test.js:
--------------------------------------------------------------------------------
1 | import * as selectors from "../searchSelectors"
2 |
3 | describe('search selectors', () => {
4 |
5 | it('should get the metadata of the songs in the search results', () => {
6 | // The search results have 2 songs
7 | const state = {
8 | search : {
9 | songs : ['2', '1'],
10 | songsById : {
11 | '1' : { id : '1'},
12 | '2' : { id : '2'}
13 | }
14 | }
15 | }
16 | // Should return an array containing song '2' and '1'
17 | const songs = selectors.searchSongsSelector(state)
18 | expect(songs).toEqual( [ state.search.songsById['2'], state.search.songsById['1'] ] )
19 | })
20 |
21 |
22 | })
--------------------------------------------------------------------------------
/src/redux/selectors/albumSelectors.js:
--------------------------------------------------------------------------------
1 | import { createSelector } from 'reselect'
2 |
3 | const getAlbums = (state) => Object.keys(state.albums.byId).map(id => state.albums.byId[id])
4 |
5 | const getArtistIdFromProps = (state, props) => props.artistId
6 |
7 | export const getAlbumsOfArtist = createSelector(
8 | [ getArtistIdFromProps, getAlbums ],
9 | (artistId, albums) => {
10 | return albums.filter(album => album.artistId === artistId)
11 | }
12 | )
13 |
14 | export const albumsSelector = createSelector(
15 | [getAlbums],
16 | albums => albums
17 | )
18 |
--------------------------------------------------------------------------------
/src/redux/selectors/artistSelectors.js:
--------------------------------------------------------------------------------
1 | import { createSelector } from 'reselect'
2 |
3 | const getArtistsByIndex = (state) => state.artists.byIndex
4 |
5 | // Returns the artists.byIndex element in a flat list with its headers
6 | export const getArtistsWithHeaders = createSelector(
7 | getArtistsByIndex,
8 | artists => artists
9 | .reduce( (accum, current) => accum.concat( [ {header: current.name}, ...current.artist] ), [] )
10 | )
11 |
--------------------------------------------------------------------------------
/src/redux/selectors/musicPlayerSelector.js:
--------------------------------------------------------------------------------
1 | import { createSelector } from 'reselect'
2 |
3 | const getQueue = (state) => state.musicPlayer.queue
4 |
5 | const getQueueSongs = (state) => state.musicPlayer.songsById
6 |
7 | const getCurrentlyPlayingIndex = (state) => state.musicPlayer.currentSongIndex
8 |
9 | const getCurrentlyPlayingId = (state) => state.musicPlayer.currentSongId
10 |
11 | export const getSongCurrentlyPlayingSelector = createSelector(
12 | [getCurrentlyPlayingId, getQueueSongs],
13 | (currentId, songs) => songs[currentId]
14 | )
15 |
16 | // Only return the songs pending to be played
17 | export const getSongsInQueueSelector = createSelector(
18 | [getQueue, getCurrentlyPlayingIndex, getQueueSongs],
19 | (queue, currentIndex, songs) => currentIndex !== null ? queue.slice(currentIndex).map( id => songs[id] ) : []
20 | )
--------------------------------------------------------------------------------
/src/redux/selectors/searchSelectors.js:
--------------------------------------------------------------------------------
1 | import { createSelector } from 'reselect'
2 |
3 | const getSearchSongsById = (state) => state.search.songsById
4 |
5 | const getSearchSongsIds = (state) => state.search.songs
6 |
7 | export const searchSongsSelector = createSelector(
8 | [getSearchSongsById, getSearchSongsIds],
9 | (songs, songIds) => songIds.map(id => songs[id])
10 | )
11 |
--------------------------------------------------------------------------------
/src/redux/selectors/songSelectors.js:
--------------------------------------------------------------------------------
1 | import { createSelector } from 'reselect'
2 |
3 | const getPlaylist = (state, props) => state.playlists.byId[props.playlistId]
4 |
5 | const getAlbum = (state, props) => state.albums.byId[props.albumId]
6 |
7 | const getSongs = (state, props) => state.songs.byId
8 |
9 | const getFavourites = (state, props) => state.favourites
10 |
11 | export const makeGetSongsOfAlbum = () => {
12 | return createSelector (
13 | [ getAlbum, getSongs ],
14 | (album, songs) => {
15 | return (album && album.song)
16 | ? album.song.reduce( (accum,songId) => {
17 | if( songs[songId] ) {
18 | accum.push(songs[songId])
19 | }
20 | return accum
21 | }, [])
22 | : []
23 | }
24 | )
25 | }
26 |
27 | export const songsSelector = createSelector(
28 | [getSongs],
29 | songs => Object.keys(songs).map(id => songs[id])
30 | )
31 |
32 | export const songsOfArtistSelector = createSelector(
33 | [getSongs, (state, props) => props.artistId ],
34 | (songs, artistId) => {
35 | return Object.keys(songs).map(id => songs[id]).filter(song => song.artistId === artistId)
36 | }
37 | )
38 |
39 | export const songsOfGenreSelector = createSelector(
40 | [getSongs, (state, props) => props.genre.value ],
41 | (songs, genre) => {
42 | return Object.keys(songs).map(id => songs[id]).filter(song => song.genre === genre)
43 | }
44 | )
45 |
46 | export const songsOfPlaylistSelector = createSelector(
47 | [getPlaylist, getSongs],
48 | (playlist, songs) => {
49 | return (playlist && playlist.songs)
50 | ? playlist.songs.reduce( (accum,songId) => {
51 | if( songs[songId] ) {
52 | accum.push(songs[songId])
53 | }
54 | return accum
55 | }, [])
56 | : []
57 | }
58 | )
59 |
60 | export const favouriteSongsSelector = createSelector(
61 | [getSongs, getFavourites],
62 | (songs, favourites) => {
63 | return favourites.reduce( (accum,songId) => {
64 | if( songs[songId] ) {
65 | accum.push(songs[songId])
66 | }
67 | return accum
68 | }, [])
69 | }
70 | )
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | import { configure } from 'enzyme'
2 | import Adapter from 'enzyme-adapter-react-16'
3 | import enableHooks from 'jest-react-hooks-shallow'
4 |
5 | configure({ adapter: new Adapter() })
6 | // pass an instance of jest to `enableHooks()`
7 | enableHooks(jest)
--------------------------------------------------------------------------------
/src/utils/formatting.js:
--------------------------------------------------------------------------------
1 |
2 | // Credit to: https://stackoverflow.com/a/37770048/3835750
3 | export function seconds_to_mss(s){
4 | let display = "0:00"
5 | if( !isNaN(s) ) {
6 | display = (s-(s%=60))/60+(9 0 ? days + (days === 1 ? " day " : " days ") : ""
22 | var hDisplay = h > 0 ? h + (h === 1 ? " hr " : " hrs ") : ""
23 | var mDisplay = m > 0 ? m + (m === 1 ? " mins" : " mins") : ""
24 | // var sDisplay = s > 0 ? s + (s === 1 ? " s" : " s") : ""
25 | display = dDisplay + hDisplay + mDisplay
26 | }
27 | return display
28 | }
29 |
30 | export const display_starred = (starred) => starred.split("T")[0]
--------------------------------------------------------------------------------
/src/utils/settings.js:
--------------------------------------------------------------------------------
1 |
2 | /* Scrobble Settings */
3 | const DEFAULT_IS_SCROBBLING = true
4 |
5 | export function getIsScrobbling() {
6 | const value = localStorage.getItem('is_scrobbling')
7 | return value !== null ? value === "true" : DEFAULT_IS_SCROBBLING
8 | }
9 |
10 | export function setIsScrobbling(value) {
11 | localStorage.setItem('is_scrobbling', value)
12 | }
13 |
14 | /* Volume Settings */
15 | const DEFAULT_VOLUME = 1.0
16 |
17 | export function getVolume() {
18 | const value = localStorage.getItem('volume')
19 | return value !== null ? parseFloat(value) : DEFAULT_VOLUME
20 | }
21 |
22 | export function setVolume(value) {
23 | localStorage.setItem('volume', value)
24 | }
25 |
26 | /* Shuffle Settings */
27 | const DEFAULT_IS_SHUFFLING = true
28 |
29 | export function getIsShuffleOn() {
30 | const value = localStorage.getItem('is_shuffling')
31 | return value !== null ? value === "true" : DEFAULT_IS_SHUFFLING
32 | }
33 |
34 | export function setShuffle(value) {
35 | localStorage.setItem('is_shuffling', value)
36 | }
37 |
38 | /* Sidebar settings */
39 | export const POSSIBLE_SIDEBAR_LINKS = [
40 | {key:"/latest" , icon: "clock-o", text:"Recently Added"},
41 | {key:"/artists" , icon: "group", text:"Artists"},
42 | {key:"/album" , icon: "th2", text:"Albums"},
43 | {key:"/genres" , icon: "venus-mars", text:"Genres"},
44 | {key:"/favourites" , icon: "star", text:"Favourites"},
45 | ]
46 |
47 | export function getSidebarDisplaySettings(mobile=false) {
48 | // Return all the possible items if the client is a mobile as we assume
49 | // they don't take too much space
50 | let items = POSSIBLE_SIDEBAR_LINKS
51 | if( !mobile ) {
52 | // if the client is not mobile, return the filtered items
53 | let savedItems = localStorage.getItem('sidebar_settings')
54 | if( savedItems !== null) {
55 | savedItems = JSON.parse(savedItems)
56 | items = POSSIBLE_SIDEBAR_LINKS.filter(item => savedItems.includes(item.key))
57 | }
58 | }
59 | return items
60 | }
61 |
62 | export function setSidebarDisplaySettings(value) {
63 | localStorage.setItem('sidebar_settings', JSON.stringify(value.map(v => v.key)) )
64 | }
--------------------------------------------------------------------------------
/src/utils/theming.js:
--------------------------------------------------------------------------------
1 |
2 | export function getAvailableThemes() {
3 | return require('../themes.config')
4 | }
5 |
6 | export function formatName(name) {
7 | let result = name
8 | // Show as light or dark theme
9 | const normalized = name.toLowerCase()
10 | if(isThemeLight(name)){
11 | result = `Light ${normalized.split("light")[1]}`
12 | }
13 | else if(isThemeDark(name)){
14 | result = `Dark ${normalized.split("dark")[1]}`
15 | }
16 | return result
17 | }
18 |
19 | export function isThemeLight(name) {
20 | return name.toLowerCase().startsWith("light")
21 | }
22 |
23 | export function isThemeDark(name) {
24 | return name.toLowerCase().startsWith("dark")
25 | }
26 |
27 | export function getThemeType(name){
28 | let result = ""
29 | if(isThemeLight(name)){
30 | result = "light"
31 | }
32 | else if(isThemeDark(name)){
33 | result = "dark"
34 | }
35 | return result
36 | }
37 |
38 | export function changeTheme(theme) {
39 | // Replace theme in memory
40 | localStorage.setItem('theme', theme)
41 | // Load new theme
42 | loadTheme(theme)
43 | }
44 |
45 | export function loadTheme(theme) {
46 | const file = `${process.env.PUBLIC_URL}/css/${theme}.css`
47 | // Put stylesheet in the document
48 | const link = document.createElement('link')
49 | link.rel = 'stylesheet';
50 | link.href = file;
51 | link.dataset.theme = theme;
52 | document.head.appendChild(link)
53 | }
54 |
55 | export function initTheme() {
56 | const savedTheme = localStorage.getItem('theme')
57 | const defaultTheme = "darkOrange"
58 | loadTheme(savedTheme || defaultTheme)
59 | }
--------------------------------------------------------------------------------
/src/utils/utils.js:
--------------------------------------------------------------------------------
1 |
2 | export function isPlaylistMineByOwner(owner){ return owner === localStorage.getItem('username') }
3 |
4 | // From: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort
5 | export function sortSongsByKey(songs, key, type) {
6 | return new Promise( (resolve, reject) => {
7 | let sortedArray = [...songs]
8 | sortedArray.sort( (a,b) => {
9 | var nameA = a[key].toUpperCase() // ignore upper and lowercase
10 | var nameB = b[key].toUpperCase() // ignore upper and lowercase
11 | if (nameA < nameB) {
12 | return type === "asc" ? -1 : 1
13 | }
14 | if (nameA > nameB) {
15 | return type === "asc" ? 1 : -1
16 | }
17 | // names must be equal
18 | return 0
19 | })
20 | resolve(sortedArray)
21 | })
22 | }
23 |
24 | export function filterSongsByValue(songs, filter) {
25 | return new Promise( (resolve, reject) => {
26 | // Check if there is a filter to apply
27 | if( filter ){
28 | // ignore upper and lowercase
29 | const fixedFilter = filter.toUpperCase()
30 | // Look for songs with this filter value in the:
31 | // title, artist or album, which are the most common keys someone
32 | // would like to filter
33 | const filteredArray = songs.filter( song => {
34 | return song.title.toUpperCase().indexOf(fixedFilter) !== -1 ||
35 | song.artist.toUpperCase().indexOf(fixedFilter) !== -1 ||
36 | song.album.toUpperCase().indexOf(fixedFilter) !== -1
37 | })
38 | resolve(filteredArray)
39 | }
40 | else {
41 | resolve(songs)
42 | }
43 | })
44 | }
45 |
46 | export function computeJointDurationOfSongs(songs) {
47 | return songs.reduce( (a,b) => ({duration: a.duration+b.duration}), {duration:0} ).duration
48 | }
--------------------------------------------------------------------------------
/themes.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | // ORANGE
3 | lightOrange: {
4 | 'base-color': '#ff3d00'
5 | },
6 | darkOrange: {
7 | 'base-color': '#ff3d00'
8 | },
9 | // BLUE
10 | lightBlue: {
11 | 'base-color': '#3498ff'
12 | },
13 | darkBlue: {
14 | 'base-color': '#3498ff'
15 | },
16 | // GREEN
17 | lightGreen: {
18 | 'base-color': '#429321'
19 | },
20 | darkGreen: {
21 | 'base-color': '#429321'
22 | },
23 | // GREY
24 | lightGrey: {
25 | 'base-color': '#607d8b'
26 | },
27 | darkGrey: {
28 | 'base-color': '#607d8b'
29 | },
30 | };
31 |
--------------------------------------------------------------------------------
/themes.content.js:
--------------------------------------------------------------------------------
1 | module.exports = (themeName) => {
2 | const isDark = themeName.startsWith("dark")
3 | return (
4 | `
5 | @import '~rsuite/lib/styles/themes/${isDark ? "dark" : "default"}/index.less';
6 |
7 | .rc-slider-rail {
8 | .rs-slider-bar
9 | }
10 |
11 | .rc-slider-track {
12 | .rs-slider-progress-bar
13 | }
14 |
15 | .rc-slider-handle {
16 | width: @slider-handle-diameter;
17 | height: @slider-handle-diameter;
18 | border: @slider-handle-border-width solid @slider-handle-default-border-color;
19 | background-color: @slider-handle-default-bg;
20 | top: 50%;
21 |
22 | &:hover {
23 | box-shadow: @slider-handle-hover-box-shadow;
24 | transition: box-shadow @slider-handle-transition, background-color @slider-handle-transition,
25 | transform @slider-handle-transition;
26 | border: @slider-handle-border-width solid @slider-handle-default-border-color;
27 | cursor: pointer;
28 | }
29 |
30 | &:active {
31 | box-shadow: none;
32 | transform: scale(1.2);
33 | border: @slider-handle-border-width solid @slider-handle-default-border-color;
34 | cursor: pointer;
35 | }
36 |
37 | &-click-focused:focus {
38 | border: @slider-handle-border-width solid @slider-handle-default-border-color;
39 | }
40 | }
41 |
42 | .loader:before {
43 | background-color: @base-color;
44 | }
45 |
46 | .music-player {
47 | background-color: ${isDark ? "@B700" : "#fff"};
48 | border-top : ${isDark ? "none" : "1px solid #f0f0f0"};
49 | }
50 |
51 | .music-player .rs-icon-inverse {
52 | color: ${isDark ? "#3b3f43" : "#f2f2f5"}
53 | }
54 |
55 | .artist-header {
56 | color: ${isDark ? "@base-color" : "inherit"};
57 | }
58 |
59 | .currently-playing {
60 | color: @base-color;
61 | font-weight: bold;
62 | }
63 |
64 | .link_to_artist:hover {
65 | background-color: @nav-item-default-hover-bg;
66 | cursor: pointer;
67 | color: @base-color;
68 | font-weight: bold;
69 | }
70 |
71 | .link_to_album:hover {
72 | background-color: @nav-item-default-hover-bg;
73 | cursor: pointer;
74 | color: @base-color;
75 | font-weight: bold;
76 | }
77 |
78 | .theme-element:hover {
79 | background-color: @nav-item-default-hover-bg;
80 | cursor: pointer;
81 | font-weight: bold;
82 | }
83 |
84 | @scrollbar-width: 8px;
85 | `
86 | )
87 | }
88 |
--------------------------------------------------------------------------------