build(ui): migrate from CRA/Jest to Vite/Vitest (#3311)

* feat: create vite project

* feat: it's alive!

* feat: `make dev` working!

* feat: replace custom serviceWorker with vite plugin

* test: replace Jest with Vitest

* fix: run prettier

* fix: skip eslint for now.

* chore: remove ui.old folder

* refactor: replace lodash.pick with simple destructuring

* fix: eslint errors (wip)

* fix: eslint errors (wip)

* fix: display-name eslint errors (wip)

* fix: no-console eslint errors (wip)

* fix: react-refresh/only-export-components eslint errors (wip)

* fix: react-refresh/only-export-components eslint errors (wip)

* fix: react-refresh/only-export-components eslint errors (wip)

* fix: react-refresh/only-export-components eslint errors (wip)

* fix: build

* fix: pwa manifest

* refactor: pwa manifest

* refactor: simplify PORT configuration

* refactor: rename simple JS files

* test: cover playlistUtils

* fix: react-image-lightbox

* feat(ui): add sourcemaps to help debug issues
This commit is contained in:
Deluan Quintão 2024-09-28 11:54:36 -04:00 committed by GitHub
parent dd48a23f92
commit fcdd30ba8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
212 changed files with 6231 additions and 31060 deletions

View file

@ -35,7 +35,7 @@ test: ##@Development Run Go tests
.PHONY: test
testall: test ##@Development Run Go and JS tests
@(cd ./ui && npm test -- --watchAll=false)
@(cd ./ui && npm run test:ci)
.PHONY: testall
lint: ##@Development Lint Go code

5
ui/.eslintignore Normal file
View file

@ -0,0 +1,5 @@
node_modules/
build/
prettier.config.js
.eslintrc
vite.config.js

61
ui/.eslintrc Normal file
View file

@ -0,0 +1,61 @@
{
"env": {
"browser": true,
"node": true,
"es6": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
// "plugin:jsx-a11y/recommended",
"eslint-config-prettier",
"plugin:@typescript-eslint/recommended",
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"warnOnUnsupportedTypeScriptVersion": false
},
"settings": {
"react": {
"version": "detect"
},
"import/resolver": {
"node": {
"paths": [
"src"
],
"extensions": [
".js",
".jsx",
".ts",
".tsx"
]
}
}
},
"plugins": ["react-refresh"],
"rules": {
"no-console": "error",
// "no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "off",
"react-refresh/only-export-components": [
"warn",
{ "allowConstantExport": true }
],
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
},
// Fix Vitest
"globals": {
"describe": "readonly",
"it": "readonly",
"expect": "readonly",
"vi": "readonly",
"beforeAll": "readonly",
"afterAll": "readonly",
"beforeEach": "readonly",
"afterEach": "readonly",
}
}

23
ui/.gitignore vendored
View file

@ -1,23 +1,6 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
node_modules
.eslintcache
build/*
!build/.gitkeep
/coverage/

View file

@ -1,5 +0,0 @@
module.exports = {
singleQuote: true,
semi: false,
arrowParens: 'always'
}

View file

@ -1,16 +0,0 @@
#!/usr/bin/env sh
set -e
export WORKBOX_DIR=public/3rdparty/workbox
rm -rf ${WORKBOX_DIR}
workbox copyLibraries build/3rdparty/
mkdir -p public/3rdparty/workbox
mv build/3rdparty/workbox-*/workbox-sw.js ${WORKBOX_DIR}
mv build/3rdparty/workbox-*/workbox-core.prod.js ${WORKBOX_DIR}
mv build/3rdparty/workbox-*/workbox-strategies.prod.js ${WORKBOX_DIR}
mv build/3rdparty/workbox-*/workbox-routing.prod.js ${WORKBOX_DIR}
mv build/3rdparty/workbox-*/workbox-navigation-preload.prod.js ${WORKBOX_DIR}
rm -rf build/3rdparty/workbox-*

View file

@ -6,11 +6,11 @@
name="description"
content="Navidrome Music Server - {{.Version}}"
/>
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="192x192" href="%PUBLIC_URL%/android-chrome-192x192.png">
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png">
<link rel="mask-icon" href="%PUBLIC_URL%/safari-pinned-tab.svg" color="#5b5fd5">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="192x192" href="/android-chrome-192x192.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5b5fd5">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">
@ -19,16 +19,7 @@
manifest.webmanifest provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.webmanifest" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<link rel="manifest" href="./manifest.webmanifest" />
<meta property="og:site_name" content="Navidrome">
<meta property="og:url" content="{{ .ShareURL }}">
<meta property="og:title" content="{{ .ShareDescription }}">
@ -57,4 +48,5 @@
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
<script type="module" src="/src/index.jsx"></script>
</html>

35326
ui/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,19 @@
{
"name": "navidrome-ui",
"version": "0.1.0",
"name": "ui",
"private": true,
"type": "module",
"scripts": {
"start": "vite",
"build": "vite build",
"serve": "vite preview",
"test": "vitest",
"test:ci": "vitest --watch=false",
"test:coverage": "vitest run --coverage --watch=false",
"type-check": "tsc --noEmit",
"lint": "eslint . --ext ts,tsx,js,jsx --report-unused-disable-directives --max-warnings 0",
"prettier": "prettier --write ./src",
"check-formatting": "prettier -c ./src"
},
"dependencies": {
"@material-ui/core": "^4.11.4",
"@material-ui/icons": "^4.11.3",
@ -14,7 +26,6 @@
"history": "^4.10.1",
"inflection": "^1.13.1",
"jwt-decode": "^4.0.0",
"lodash.pick": "^4.4.0",
"lodash.throttle": "^4.1.1",
"navidrome-music-player": "4.25.1",
"prop-types": "^15.7.2",
@ -38,54 +49,30 @@
"uuid": "^10.0.0"
},
"devDependencies": {
"@nabla/vite-plugin-eslint": "^2.0.2",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^7.0.2",
"@testing-library/user-event": "^14.5.2",
"css-mediaquery": "^0.1.2",
"prettier": "3.3.3",
"@types/node": "^22.6.1",
"@types/react": "^17.0.2",
"@types/react-dom": "^17.0.2",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0",
"@vitejs/plugin-react-swc": "^3.7.0",
"@vitest/coverage-v8": "^2.1.1",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"jsdom": "^25.0.1",
"prettier": "^3.3.3",
"ra-test": "^3.19.12",
"react-scripts": "5.0.1",
"workbox-cli": "^7.0.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"lint": "eslint --max-warnings 0 src/*.js src/**/*.js",
"prettier": "prettier --write src/*.js src/**/*.js",
"check-formatting": "prettier -c src/*.js src/**/*.js",
"update-workbox": "bin/update-workbox.sh"
},
"homepage": ".",
"proxy": "http://localhost:4633/",
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
],
"overrides": [
{
"files": [
"src/**/index.js",
"src/themes/*.js"
],
"rules": {
"import/no-anonymous-default-export": "off"
}
}
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
"typescript": "^5.1.6",
"vite": "^5.3.5",
"vite-plugin-pwa": "^0.20.5",
"vitest": "^2.1.1"
}
}

5
ui/prettier.config.js Normal file
View file

@ -0,0 +1,5 @@
export default {
singleQuote: true,
semi: false,
arrowParens: "always",
};

View file

@ -1,2 +0,0 @@
this.workbox=this.workbox||{},this.workbox.core=function(t){"use strict";try{self["workbox:core:6.2.4"]&&_()}catch(t){}const e=(t,...e)=>{let n=t;return e.length>0&&(n+=" :: "+JSON.stringify(e)),n};class n extends Error{constructor(t,n){super(e(t,n)),this.name=t,this.details=n}}const r=new Set;const o={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:"undefined"!=typeof registration?registration.scope:""},s=t=>[o.prefix,t,o.suffix].filter((t=>t&&t.length>0)).join("-"),i={updateDetails:t=>{(t=>{for(const e of Object.keys(o))t(e)})((e=>{"string"==typeof t[e]&&(o[e]=t[e])}))},getGoogleAnalyticsName:t=>t||s(o.googleAnalytics),getPrecacheName:t=>t||s(o.precache),getPrefix:()=>o.prefix,getRuntimeName:t=>t||s(o.runtime),getSuffix:()=>o.suffix};function c(t,e){const n=new URL(t);for(const t of e)n.searchParams.delete(t);return n.href}let a,u;function f(){if(void 0===u){const t=new Response("");if("body"in t)try{new Response(t.body),u=!0}catch(t){u=!1}u=!1}return u}function l(t){return new Promise((e=>setTimeout(e,t)))}var g=Object.freeze({__proto__:null,assert:null,cacheMatchIgnoreParams:async function(t,e,n,r){const o=c(e.url,n);if(e.url===o)return t.match(e,r);const s=Object.assign(Object.assign({},r),{ignoreSearch:!0}),i=await t.keys(e,s);for(const e of i){if(o===c(e.url,n))return t.match(e,r)}},cacheNames:i,canConstructReadableStream:function(){if(void 0===a)try{new ReadableStream({start(){}}),a=!0}catch(t){a=!1}return a},canConstructResponseFromBodyStream:f,dontWaitFor:function(t){t.then((()=>{}))},Deferred:class{constructor(){this.promise=new Promise(((t,e)=>{this.resolve=t,this.reject=e}))}},executeQuotaErrorCallbacks:async function(){for(const t of r)await t()},getFriendlyURL:t=>new URL(String(t),location.href).href.replace(new RegExp("^"+location.origin),""),logger:null,resultingClientExists:async function(t){if(!t)return;let e=await self.clients.matchAll({type:"window"});const n=new Set(e.map((t=>t.id)));let r;const o=performance.now();for(;performance.now()-o<2e3&&(e=await self.clients.matchAll({type:"window"}),r=e.find((e=>t?e.id===t:!n.has(e.id))),!r);)await l(100);return r},timeout:l,waitUntil:function(t,e){const n=e();return t.waitUntil(n),n},WorkboxError:n});const w={get googleAnalytics(){return i.getGoogleAnalyticsName()},get precache(){return i.getPrecacheName()},get prefix(){return i.getPrefix()},get runtime(){return i.getRuntimeName()},get suffix(){return i.getSuffix()}};return t._private=g,t.cacheNames=w,t.clientsClaim=function(){self.addEventListener("activate",(()=>self.clients.claim()))},t.copyResponse=async function(t,e){let r=null;if(t.url){r=new URL(t.url).origin}if(r!==self.location.origin)throw new n("cross-origin-copy-response",{origin:r});const o=t.clone(),s={headers:new Headers(o.headers),status:o.status,statusText:o.statusText},i=e?e(s):s,c=f()?o.body:await o.blob();return new Response(c,i)},t.registerQuotaErrorCallback=function(t){r.add(t)},t.setCacheNameDetails=function(t){i.updateDetails(t)},t.skipWaiting=function(){self.skipWaiting()},t}({});
//# sourceMappingURL=workbox-core.prod.js.map

View file

@ -1,2 +0,0 @@
this.workbox=this.workbox||{},this.workbox.navigationPreload=function(t){"use strict";try{self["workbox:navigation-preload:6.2.4"]&&_()}catch(t){}function e(){return Boolean(self.registration&&self.registration.navigationPreload)}return t.disable=function(){e()&&self.addEventListener("activate",(t=>{t.waitUntil(self.registration.navigationPreload.disable().then((()=>{})))}))},t.enable=function(t){e()&&self.addEventListener("activate",(e=>{e.waitUntil(self.registration.navigationPreload.enable().then((()=>{t&&self.registration.navigationPreload.setHeaderValue(t)})))}))},t.isSupported=e,t}({});
//# sourceMappingURL=workbox-navigation-preload.prod.js.map

View file

@ -1,2 +0,0 @@
this.workbox=this.workbox||{},this.workbox.routing=function(t,e){"use strict";try{self["workbox:routing:6.2.4"]&&_()}catch(t){}const s=t=>t&&"object"==typeof t?t:{handle:t};class r{constructor(t,e,r="GET"){this.handler=s(e),this.match=t,this.method=r}setCatchHandler(t){this.catchHandler=s(t)}}class n extends r{constructor(t,e,s){super((({url:e})=>{const s=t.exec(e.href);if(s&&(e.origin===location.origin||0===s.index))return s.slice(1)}),e,s)}}class i{constructor(){this.ut=new Map,this.ft=new Map}get routes(){return this.ut}addFetchListener(){self.addEventListener("fetch",(t=>{const{request:e}=t,s=this.handleRequest({request:e,event:t});s&&t.respondWith(s)}))}addCacheListener(){self.addEventListener("message",(t=>{if(t.data&&"CACHE_URLS"===t.data.type){const{payload:e}=t.data,s=Promise.all(e.urlsToCache.map((e=>{"string"==typeof e&&(e=[e]);const s=new Request(...e);return this.handleRequest({request:s,event:t})})));t.waitUntil(s),t.ports&&t.ports[0]&&s.then((()=>t.ports[0].postMessage(!0)))}}))}handleRequest({request:t,event:e}){const s=new URL(t.url,location.href);if(!s.protocol.startsWith("http"))return;const r=s.origin===location.origin,{params:n,route:i}=this.findMatchingRoute({event:e,request:t,sameOrigin:r,url:s});let o=i&&i.handler;const u=t.method;if(!o&&this.ft.has(u)&&(o=this.ft.get(u)),!o)return;let c;try{c=o.handle({url:s,request:t,event:e,params:n})}catch(t){c=Promise.reject(t)}const a=i&&i.catchHandler;return c instanceof Promise&&(this.lt||a)&&(c=c.catch((async r=>{if(a)try{return await a.handle({url:s,request:t,event:e,params:n})}catch(t){t instanceof Error&&(r=t)}if(this.lt)return this.lt.handle({url:s,request:t,event:e});throw r}))),c}findMatchingRoute({url:t,sameOrigin:e,request:s,event:r}){const n=this.ut.get(s.method)||[];for(const i of n){let n;const o=i.match({url:t,sameOrigin:e,request:s,event:r});if(o)return n=o,(Array.isArray(n)&&0===n.length||o.constructor===Object&&0===Object.keys(o).length||"boolean"==typeof o)&&(n=void 0),{route:i,params:n}}return{}}setDefaultHandler(t,e="GET"){this.ft.set(e,s(t))}setCatchHandler(t){this.lt=s(t)}registerRoute(t){this.ut.has(t.method)||this.ut.set(t.method,[]),this.ut.get(t.method).push(t)}unregisterRoute(t){if(!this.ut.has(t.method))throw new e.WorkboxError("unregister-route-but-not-found-with-method",{method:t.method});const s=this.ut.get(t.method).indexOf(t);if(!(s>-1))throw new e.WorkboxError("unregister-route-route-not-registered");this.ut.get(t.method).splice(s,1)}}let o;const u=()=>(o||(o=new i,o.addFetchListener(),o.addCacheListener()),o);return t.NavigationRoute=class extends r{constructor(t,{allowlist:e=[/./],denylist:s=[]}={}){super((t=>this.dt(t)),t),this.wt=e,this.gt=s}dt({url:t,request:e}){if(e&&"navigate"!==e.mode)return!1;const s=t.pathname+t.search;for(const t of this.gt)if(t.test(s))return!1;return!!this.wt.some((t=>t.test(s)))}},t.RegExpRoute=n,t.Route=r,t.Router=i,t.registerRoute=function(t,s,i){let o;if("string"==typeof t){const e=new URL(t,location.href);o=new r((({url:t})=>t.href===e.href),s,i)}else if(t instanceof RegExp)o=new n(t,s,i);else if("function"==typeof t)o=new r(t,s,i);else{if(!(t instanceof r))throw new e.WorkboxError("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});o=t}return u().registerRoute(o),o},t.setCatchHandler=function(t){u().setCatchHandler(t)},t.setDefaultHandler=function(t){u().setDefaultHandler(t)},t}({},workbox.core._private);
//# sourceMappingURL=workbox-routing.prod.js.map

File diff suppressed because one or more lines are too long

View file

@ -1,2 +0,0 @@
!function(){"use strict";try{self["workbox:sw:6.3.0"]&&_()}catch(t){}const t={backgroundSync:"background-sync",broadcastUpdate:"broadcast-update",cacheableResponse:"cacheable-response",core:"core",expiration:"expiration",googleAnalytics:"offline-ga",navigationPreload:"navigation-preload",precaching:"precaching",rangeRequests:"range-requests",routing:"routing",strategies:"strategies",streams:"streams",recipes:"recipes"};self.workbox=new class{constructor(){return this.v={},this.Pt={debug:"localhost"===self.location.hostname,modulePathPrefix:null,modulePathCb:null},this.$t=this.Pt.debug?"dev":"prod",this.Ct=!1,new Proxy(this,{get(e,s){if(e[s])return e[s];const o=t[s];return o&&e.loadModule("workbox-"+o),e[s]}})}setConfig(t={}){if(this.Ct)throw new Error("Config must be set before accessing workbox.* modules");Object.assign(this.Pt,t),this.$t=this.Pt.debug?"dev":"prod"}loadModule(t){const e=this.jt(t);try{importScripts(e),this.Ct=!0}catch(s){throw console.error(`Unable to import module '${t}' from '${e}'.`),s}}jt(t){if(this.Pt.modulePathCb)return this.Pt.modulePathCb(t,this.Pt.debug);let e=["https://storage.googleapis.com/workbox-cdn/releases/6.3.0"];const s=`${t}.${this.$t}.js`,o=this.Pt.modulePathPrefix;return o&&(e=o.split("/"),""===e[e.length-1]&&e.splice(e.length-1,1)),e.push(s),e.join("/")}}}();
//# sourceMappingURL=workbox-sw.js.map

View file

@ -1,22 +0,0 @@
{
"name": "Navidrome",
"short_name": "Navidrome",
"description": "Navidrome, an open source web-based music collection server and streamer",
"categories": ["music", "entertainment"],
"display": "standalone",
"start_url": "./",
"background_color": "white",
"theme_color": "blue",
"icons": [
{
"src": "./android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "./android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View file

@ -1,53 +0,0 @@
// documentation: https://developers.google.com/web/tools/workbox/modules/workbox-sw
importScripts('3rdparty/workbox/workbox-sw.js')
workbox.setConfig({
modulePathPrefix: '3rdparty/workbox/',
debug: false,
})
workbox.loadModule('workbox-core');
workbox.loadModule('workbox-strategies');
workbox.loadModule('workbox-routing');
workbox.loadModule('workbox-navigation-preload');
workbox.core.clientsClaim();
self.skipWaiting();
addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
skipWaiting();
}
});
const CACHE_NAME = 'offline-html';
// This assumes /offline.html is a URL for your self-contained
// (no external images or styles) offline page.
const FALLBACK_HTML_URL = './offline.html';
// Populate the cache with the offline HTML page when the
// service worker is installed.
self.addEventListener('install', async (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.add(FALLBACK_HTML_URL))
);
});
const networkOnly = new workbox.strategies.NetworkOnly();
const navigationHandler = async (params) => {
try {
// Attempt a network request.
return await networkOnly.handle(params);
} catch (error) {
// If it fails, return the cached HTML.
return caches.match(FALLBACK_HTML_URL, {
cacheName: CACHE_NAME,
});
}
};
// Register this strategy to handle all navigations.
workbox.routing.registerRoute(
new workbox.routing.NavigationRoute(navigationHandler)
);

View file

@ -1,10 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head><title>Navidrome</title></head>
<body style="margin:0">
<p id="errorMessageDescription" style="text-align:center;font-size:21px;font-family:arial;margin-top:28px">
It looks like we are having trouble connecting.
<br/>
Please check your internet connection and try again.</p>
</body>
</html>

View file

@ -74,7 +74,7 @@ const App = () => (
const Admin = (props) => {
useChangeThemeColor()
/* eslint-disable react/jsx-key */
return (
<RAAdmin
disableTelemetry
@ -125,6 +125,7 @@ const Admin = (props) => {
]}
</RAAdmin>
)
/* eslint-enable react/jsx-key */
}
const AppWithHotkeys = () => {

View file

@ -106,7 +106,7 @@ const useStyles = makeStyles(
},
)
export const useGetHandleGenreClick = (width) => {
const useGetHandleGenreClick = (width) => {
const [perPage] = useAlbumsPerPage(width)
return (id) => {
@ -244,6 +244,7 @@ const AlbumDetails = (props) => {
}
})
.catch((e) => {
// eslint-disable-next-line no-console
console.error('error on album page', e)
})
}, [record])

View file

@ -210,4 +210,6 @@ const AlbumGridView = ({ albumListType, loaded, loading, ...props }) => {
return hide ? <Loading /> : <LoadedAlbumGrid {...props} />
}
export default withWidth()(AlbumGridView)
const AlbumGridViewWithWidth = withWidth()(AlbumGridView)
export default AlbumGridViewWithWidth

View file

@ -152,4 +152,6 @@ const AlbumList = (props) => {
)
}
export default withWidth()(AlbumList)
const AlbumListWithWidth = withWidth()(AlbumList)
export default AlbumListWithWidth

View file

@ -67,6 +67,8 @@ const AlbumViewToggler = React.forwardRef(
},
)
AlbumViewToggler.displayName = 'AlbumViewToggler'
const AlbumListActions = ({
currentSort,
className,

View file

@ -1,12 +1,12 @@
import React, { useMemo } from 'react'
import {
BulkActionsToolbar,
ListToolbar,
TextField,
NumberField,
useVersion,
useListContext,
FunctionField,
ListToolbar,
NumberField,
TextField,
useListContext,
useVersion,
} from 'react-admin'
import clsx from 'clsx'
import { useDispatch } from 'react-redux'
@ -15,22 +15,23 @@ import { makeStyles } from '@material-ui/core/styles'
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
import { playTracks } from '../actions'
import {
ArtistLinkField,
DateField,
DurationField,
QualityInfo,
RatingField,
SizeField,
SongBulkActions,
SongContextMenu,
SongDatagrid,
SongInfo,
SongTitleField,
RatingField,
QualityInfo,
useSelectedFields,
useResourceRefresh,
DateField,
SizeField,
ArtistLinkField,
useSelectedFields,
} from '../common'
import config from '../config'
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
import { removeAlbumCommentsFromSongs } from './utils.js'
const useStyles = makeStyles(
(theme) => ({
@ -193,14 +194,6 @@ const AlbumSongs = (props) => {
)
}
export const removeAlbumCommentsFromSongs = ({ album, data }) => {
if (album?.comment && data) {
Object.values(data).forEach((song) => {
song.comment = ''
})
}
}
const SanitizedAlbumSongs = (props) => {
removeAlbumCommentsFromSongs(props)
const { loaded, loading, total, ...rest } = useListContext(props)

7
ui/src/album/utils.js Normal file
View file

@ -0,0 +1,7 @@
export const removeAlbumCommentsFromSongs = ({ album, data }) => {
if (album?.comment && data) {
Object.values(data).forEach((song) => {
song.comment = ''
})
}
}

View file

@ -1,4 +1,4 @@
import { removeAlbumCommentsFromSongs } from './AlbumSongs'
import { removeAlbumCommentsFromSongs } from './utils.js'
describe('removeAlbumCommentsFromSongs', () => {
const data = { 1: { comment: 'one' }, 2: { comment: 'two' } }

View file

@ -179,4 +179,6 @@ const ArtistList = (props) => {
)
}
export default withWidth()(ArtistList)
const ArtistListWithWidth = withWidth()(ArtistList)
export default ArtistListWithWidth

View file

@ -31,6 +31,7 @@ const ArtistDetails = (props) => {
}
})
.catch((e) => {
// eslint-disable-next-line no-console
console.error('error on artist page', e)
})
}, [record.id])

View file

@ -66,4 +66,6 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => {
)
})
AudioTitle.displayName = 'AudioTitle'
export default AudioTitle

View file

@ -257,6 +257,7 @@ const Player = () => {
dispatch(currentPlaying(info))
dataProvider
.getOne('keepalive', { id: info.trackId })
// eslint-disable-next-line no-console
.catch((e) => console.log('Keepalive error:', e))
},
[dispatch, dataProvider],

View file

@ -8,6 +8,7 @@ if (config.auth) {
try {
storeAuthenticationInfo(config.auth)
} catch (e) {
// eslint-disable-next-line no-console
console.log(e)
}
}

View file

@ -2,17 +2,7 @@ import React from 'react'
import PropTypes from 'prop-types'
import { Link } from 'react-admin'
import { withWidth } from '@material-ui/core'
import { useAlbumsPerPage } from './index'
import config from '../config'
export const useGetHandleArtistClick = (width) => {
const [perPage] = useAlbumsPerPage(width)
return (id) => {
return config.devShowArtistPage && id !== config.variousArtistsId
? `/artist/${id}/show`
: `/album?filter={"artist_id":"${id}"}&order=ASC&sort=max_year&displayedFilters={"compilation":true}&perPage=${perPage}`
}
}
import { useGetHandleArtistClick } from './useGetHandleArtistClick'
export const ArtistLinkField = withWidth()(({
record,

View file

@ -8,6 +8,7 @@ export const DurationField = ({ source, ...rest }) => {
try {
return <span>{formatDuration(record[source])}</span>
} catch (e) {
// eslint-disable-next-line no-console
console.log('Error in DurationField! Record:', record)
return <span>00:00</span>
}

View file

@ -46,6 +46,8 @@ export const MultiLineTextField = memo(
},
)
MultiLineTextField.displayName = 'MultiLineTextField'
MultiLineTextField.defaultProps = {
addLabel: true,
firstLine: 0,

View file

@ -1,20 +1,7 @@
import React from 'react'
import PropTypes from 'prop-types'
import { useRecordContext } from 'react-admin'
export const formatRange = (record, source) => {
const nameCapitalized = source.charAt(0).toUpperCase() + source.slice(1)
const min = record[`min${nameCapitalized}`]
const max = record[`max${nameCapitalized}`]
let range = []
if (min) {
range.push(min)
}
if (max && max !== min) {
range.push(max)
}
return range.join('-')
}
import { formatRange } from './formatRange'
export const RangeField = ({ className, source, ...rest }) => {
const record = useRecordContext(rest)

View file

@ -100,6 +100,8 @@ const ReleaseRow = forwardRef(
},
)
ReleaseRow.displayName = 'ReleaseRow'
const DiscSubtitleRow = forwardRef(
({ record, onClick, colSpan, contextAlwaysVisible }, ref) => {
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
@ -146,6 +148,8 @@ const DiscSubtitleRow = forwardRef(
},
)
DiscSubtitleRow.displayName = 'DiscSubtitleRow'
export const SongDatagridRow = ({
record,
children,

View file

@ -0,0 +1,12 @@
import { Children, cloneElement, isValidElement } from 'react'
import { isWritable } from './playlistUtils.js'
export const Writable = (props) => {
const { record = {}, children } = props
if (isWritable(record.ownerId)) {
return Children.map(children, (child) =>
isValidElement(child) ? cloneElement(child, props) : child,
)
}
return null
}

View file

@ -0,0 +1,13 @@
export const formatRange = (record, source) => {
const nameCapitalized = source.charAt(0).toUpperCase() + source.slice(1)
const min = record[`min${nameCapitalized}`]
const max = record[`max${nameCapitalized}`]
let range = []
if (min) {
range.push(min)
}
if (max && max !== min) {
range.push(max)
}
return range.join('-')
}

View file

@ -25,6 +25,7 @@ export * from './LoveButton'
export * from './Title'
export * from './SongBulkActions'
export * from './useAlbumsPerPage'
export * from './useGetHandleArtistClick'
export * from './useInterval'
export * from './useResourceRefresh'
export * from './useToggleLove'
@ -37,3 +38,5 @@ export * from './useRating'
export * from './useSelectedFields'
export * from './ToggleFieldsMenu'
export * from './QualityInfo'
export * from './formatRange.js'
export * from './playlistUtils.js'

View file

@ -1,5 +1,3 @@
import { cloneElement, Children, isValidElement } from 'react'
export const isWritable = (ownerId) => {
return (
localStorage.getItem('userId') === ownerId ||
@ -11,16 +9,6 @@ export const isReadOnly = (ownerId) => {
return !isWritable(ownerId)
}
export const Writable = (props) => {
const { record = {}, children } = props
if (isWritable(record.ownerId)) {
return Children.map(children, (child) =>
isValidElement(child) ? cloneElement(child, props) : child,
)
}
return null
}
export const isSmartPlaylist = (pls) => !!pls.rules
export const canChangeTracks = (pls) =>

View file

@ -0,0 +1,78 @@
import {
isWritable,
isReadOnly,
isSmartPlaylist,
canChangeTracks,
} from './playlistUtils'
describe('playlistUtils', () => {
beforeEach(() => {
localStorage.clear()
})
describe('isWritable', () => {
it('returns true if user is the owner', () => {
localStorage.setItem('userId', 'user1')
expect(isWritable('user1')).toBe(true)
})
it('returns true if user is an admin', () => {
localStorage.setItem('role', 'admin')
expect(isWritable('user1')).toBe(true)
})
it('returns false if user is not the owner and not an admin', () => {
localStorage.setItem('userId', 'user2')
expect(isWritable('user1')).toBe(false)
})
})
describe('isReadOnly', () => {
it('returns true if user is not the owner and not an admin', () => {
localStorage.setItem('userId', 'user2')
expect(isReadOnly('user1')).toBe(true)
})
it('returns false if user is the owner', () => {
localStorage.setItem('userId', 'user1')
expect(isReadOnly('user1')).toBe(false)
})
it('returns false if user is an admin', () => {
localStorage.setItem('role', 'admin')
expect(isReadOnly('user1')).toBe(false)
})
})
describe('isSmartPlaylist', () => {
it('returns true if playlist has rules', () => {
const playlist = { rules: [] }
expect(isSmartPlaylist(playlist)).toBe(true)
})
it('returns false if playlist does not have rules', () => {
const playlist = {}
expect(isSmartPlaylist(playlist)).toBe(false)
})
})
describe('canChangeTracks', () => {
it('returns true if user is the owner and playlist is not smart', () => {
localStorage.setItem('userId', 'user1')
const playlist = { ownerId: 'user1' }
expect(canChangeTracks(playlist)).toBe(true)
})
it('returns false if user is not the owner', () => {
localStorage.setItem('userId', 'user2')
const playlist = { ownerId: 'user1' }
expect(canChangeTracks(playlist)).toBe(false)
})
it('returns false if playlist is smart', () => {
localStorage.setItem('userId', 'user1')
const playlist = { ownerId: 'user1', rules: [] }
expect(canChangeTracks(playlist)).toBe(false)
})
})
})

View file

@ -0,0 +1,11 @@
import { useAlbumsPerPage } from './useAlbumsPerPage'
import config from '../config.js'
export const useGetHandleArtistClick = (width) => {
const [perPage] = useAlbumsPerPage(width)
return (id) => {
return config.devShowArtistPage && id !== config.variousArtistsId
? `/artist/${id}/show`
: `/album?filter={"artist_id":"${id}"}&order=ASC&sort=max_year&displayedFilters={"compilation":true}&perPage=${perPage}`
}
}

View file

@ -25,6 +25,7 @@ export const useRating = (resource, record) => {
}
})
.catch((e) => {
// eslint-disable-next-line no-console
console.log('Error encountered: ' + e)
})
}, [dataProvider, record, resource])
@ -35,6 +36,7 @@ export const useRating = (resource, record) => {
.setRating(id, val)
.then(refreshRating)
.catch((e) => {
// eslint-disable-next-line no-console
console.log('Error setting star rating: ', e)
notify('ra.page.error', 'warning')
if (mountedRef.current) {

View file

@ -1,47 +1,57 @@
import { vi } from 'vitest'
import * as React from 'react'
import * as Redux from 'react-redux'
import * as RA from 'react-admin'
import { useResourceRefresh } from './useResourceRefresh'
jest.mock('react', () => ({
...jest.requireActual('react'),
useState: jest.fn(),
}))
vi.mock('react', async () => {
const actual = await vi.importActual('react')
return {
...actual,
useState: vi.fn(),
}
})
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
}))
vi.mock('react-redux', async () => {
const actual = await vi.importActual('react-redux')
return {
...actual,
useSelector: vi.fn(),
}
})
jest.mock('react-admin', () => ({
...jest.requireActual('react-admin'),
useRefresh: jest.fn(),
useDataProvider: jest.fn(),
}))
vi.mock('react-admin', async () => {
const actual = await vi.importActual('react-admin')
return {
...actual,
useRefresh: vi.fn(),
useDataProvider: vi.fn(),
}
})
describe('useResourceRefresh', () => {
const setState = jest.fn()
const setState = vi.fn()
const useStateMock = (initState) => [initState, setState]
const refresh = jest.fn()
const refresh = vi.fn()
const useRefreshMock = () => refresh
const getMany = jest.fn()
const getMany = vi.fn()
const useDataProviderMock = () => ({ getMany })
let lastTime
beforeEach(() => {
jest.spyOn(React, 'useState').mockImplementation(useStateMock)
jest.spyOn(RA, 'useRefresh').mockImplementation(useRefreshMock)
jest.spyOn(RA, 'useDataProvider').mockImplementation(useDataProviderMock)
vi.spyOn(React, 'useState').mockImplementation(useStateMock)
vi.spyOn(RA, 'useRefresh').mockImplementation(useRefreshMock)
vi.spyOn(RA, 'useDataProvider').mockImplementation(useDataProviderMock)
lastTime = new Date(new Date().valueOf() + 1000)
})
afterEach(() => {
jest.clearAllMocks()
vi.clearAllMocks()
})
it('stores last time checked, to avoid redundant runs', () => {
const useSelectorMock = () => ({ lastReceived: lastTime })
jest.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
useResourceRefresh()
@ -49,9 +59,9 @@ describe('useResourceRefresh', () => {
})
it("does not run again if lastTime didn't change", () => {
jest.spyOn(React, 'useState').mockImplementation(() => [lastTime, setState])
vi.spyOn(React, 'useState').mockImplementation(() => [lastTime, setState])
const useSelectorMock = () => ({ lastReceived: lastTime })
jest.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
useResourceRefresh()
@ -64,7 +74,7 @@ describe('useResourceRefresh', () => {
lastReceived: lastTime,
resources: { '*': '*' },
})
jest.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
useResourceRefresh()
@ -77,7 +87,7 @@ describe('useResourceRefresh', () => {
lastReceived: lastTime,
resources: { album: ['*'] },
})
jest.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
useResourceRefresh()
@ -90,7 +100,7 @@ describe('useResourceRefresh', () => {
lastReceived: lastTime,
resources: { album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] },
})
jest.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
useResourceRefresh()
@ -107,7 +117,7 @@ describe('useResourceRefresh', () => {
lastReceived: lastTime,
resources: { '*': '*' },
})
jest.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
useResourceRefresh('album')
@ -120,7 +130,7 @@ describe('useResourceRefresh', () => {
lastReceived: lastTime,
resources: { album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] },
})
jest.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
useResourceRefresh('song')

View file

@ -31,6 +31,7 @@ export const useToggleLove = (resource, record = {}) => {
toggle(record.id)
.then(refreshRecord)
.catch((e) => {
// eslint-disable-next-line no-console
console.log('Error toggling love: ', e)
notify('ra.page.error', 'warning')
if (mountedRef.current) {

View file

@ -10,6 +10,7 @@ export function useTraceUpdate(props) {
return ps
}, {})
if (Object.keys(changedProps).length > 0) {
// eslint-disable-next-line no-console
console.log('Changed props:', changedProps)
}
prev.current = props

View file

@ -6,14 +6,14 @@ const dataProvider = jsonServerProvider(REST_URL, httpClient)
const mapResource = (resource, params) => {
switch (resource) {
case 'playlistTrack':
case 'playlistTrack': {
// /api/playlistTrack?playlist_id=123 => /api/playlist/123/tracks
let plsId = '0'
if (params.filter) {
plsId = params.filter.playlist_id
}
return [`playlist/${plsId}/tracks`, params]
}
default:
return [resource, params]
}

Some files were not shown because too many files have changed in this diff Show more