From 678228d2232bdb2d37f3c36eba1e4ac4d197459f Mon Sep 17 00:00:00 2001 From: Sander Vocke Date: Mon, 3 Aug 2020 14:52:08 +0200 Subject: [PATCH] Fix pagination and song / artist search via URL query. --- client/package.json | 1 + client/src/App.tsx | 83 ++++++++++++++----- client/src/api.ts | 11 ++- client/src/components/FilterControl.tsx | 10 +-- client/src/types/Query.tsx | 8 ++ client/yarn.lock | 5 ++ server/endpoints/QuerySongsEndpointHandler.ts | 18 ++-- 7 files changed, 101 insertions(+), 35 deletions(-) diff --git a/client/package.json b/client/package.json index 5c46bb5..b430876 100644 --- a/client/package.json +++ b/client/package.json @@ -15,6 +15,7 @@ "@types/react-dom": "^16.9.0", "@types/react-router": "^5.1.8", "@types/react-router-dom": "^5.1.5", + "jsurl": "^0.1.5", "lodash": "^4.17.19", "material-table": "^1.64.0", "react": "^16.13.1", diff --git a/client/src/App.tsx b/client/src/App.tsx index 0a9bf6e..bb39ca6 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -8,7 +8,7 @@ import AppBar, { ActiveTab as AppBarActiveTab } from './components/AppBar'; import ItemList from './components/ItemList'; import ItemListItem from './components/ItemListItem'; import FilterControl from './components/FilterControl'; -import { SongQuery, toApiQuery } from './types/Query'; +import { SongQuery, toApiQuery, isSongQuery } from './types/Query'; import { SongDisplayItem, ArtistDisplayItem } from './types/DisplayItem'; import { ReactComponent as GooglePlayIcon } from './assets/googleplaymusic_icon.svg'; @@ -17,9 +17,11 @@ import { Switch, Route, useHistory, + useLocation, Redirect } from "react-router-dom"; -import { timeLog } from 'console'; + +const JSURL = require('jsurl'); interface SongItemProps { song: serverApi.SongDetails, @@ -46,13 +48,12 @@ function SongItem(props: SongItemProps) { tagNames: props.song.tags && props.song.tags.map((tag: serverApi.TagDetails) => { return tag.name; }) || [], - storeLinks: [] - // json.storeLinks.map((url: String) => { - // return { - // icon: getStoreIcon(url), - // url: url - // } - // }) + storeLinks: props.song.storeLinks && props.song.storeLinks.map((url: String) => { + return { + icon: getStoreIcon(url), + url: url + } + }) || [], } return ; @@ -137,18 +138,57 @@ function ArtistList() { function AppBody() { const history = useHistory(); - const [songQuery, setSongQuery] = useState({ - 'titleLike': '' - }); + const location = useLocation(); + const queryParams = new URLSearchParams(location.search); + + // If we have an invalid query, change to the default one. + const songQuery: SongQuery | undefined = JSURL.tryParse(queryParams.get('query'), undefined); + const [songs, setSongs] = useState([]); + const offset: number | undefined = queryParams.get('offset') ? parseInt(queryParams.get('offset') || '0') : undefined; + const limit: number | undefined = queryParams.get('limit') ? parseInt(queryParams.get('limit') || '0') : undefined; - React.useEffect(() => { - const query = songQuery; + const fixQueryParams = () => { + var fixed = false; + if (!isSongQuery(songQuery)) { + console.log("query"); + queryParams.set('query', JSURL.stringify({ + 'titleLike': '' + })); + fixed = true; + } + if (offset == undefined) { + console.log("offset", offset); + queryParams.set('offset', '0'); + fixed = true; + } + if (limit == undefined) { + console.log("limit"); + queryParams.set('limit', '20'); + fixed = true; + } + console.log("fixed", fixed); + return fixed; + } + + const pushQueryParams = () => { + history.push({ + search: "?" + queryParams.toString() + }) + } + + useEffect(() => { + if (fixQueryParams()) { + pushQueryParams(); + return; + } + + const query: SongQuery = songQuery || { 'titleLike': '' }; setSongs([]); const request: serverApi.QuerySongsRequest = { query: toApiQuery(query), - offset: 0, - limit: 20, + offset: offset || 0, + limit: limit || 0, } const requestOpts = { method: 'POST', @@ -160,7 +200,7 @@ function AppBody() { .then((json: any) => { 'songs' in json && query === songQuery && setSongs(json.songs); }); - }, [songQuery]); + }, [location]); const onAppBarTabChange = (value: AppBarActiveTab) => { switch (value) { @@ -178,12 +218,17 @@ function AppBody() { return (
- + { setSongQuery(query); }} + onChangeQuery={(squery: SongQuery) => { + if (squery != songQuery) { + queryParams.set('query', JSURL.stringify(squery)); + pushQueryParams(); + } + }} /> diff --git a/client/src/api.ts b/client/src/api.ts index f0d31c4..6ff2174 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -10,17 +10,20 @@ export interface ArtistDetails { id: Number, name: String, + storeLinks?: String[], } export interface TagDetails { id: Number, name: String, parent?: TagDetails, + storeLinks?: String[], } export interface SongDetails { id: Number, title: String, artists?: ArtistDetails[], tags?: TagDetails[], + storeLinks?: String[], } // Query for songs (POST). @@ -39,8 +42,8 @@ export enum SongQueryFilterOp { export enum SongQueryElemProperty { title = "title", id = "id", - artistIds = "artistIds", - albumIds = "albumIds", + artistNames = "artistNames", + albumNames = "albumNames", } export interface SongQueryElem { prop?: SongQueryElemProperty, @@ -52,8 +55,8 @@ export interface SongQueryElem { export interface SongQuery extends SongQueryElem { } export interface QuerySongsRequest { query: SongQuery, - offset: Number, - limit: Number, + offset: number, + limit: number, } export interface QuerySongsResponse { songs: SongDetails[] diff --git a/client/src/components/FilterControl.tsx b/client/src/components/FilterControl.tsx index 92e8e7c..7265873 100644 --- a/client/src/components/FilterControl.tsx +++ b/client/src/components/FilterControl.tsx @@ -46,14 +46,14 @@ function ArtistFilterControl(props: ArtistFilterControlProps) { } export interface IProps { - query: SongQuery, + query: SongQuery | undefined, onChangeQuery: (query: SongQuery) => void, } export default function FilterControl(props: IProps) { const selectOptions: string[] = ['Title', 'Artist']; - const selectOption: string = (isTitleQuery(props.query) && 'Title') || - (isArtistQuery(props.query) && 'Artist') || + const selectOption: string = (props.query && isTitleQuery(props.query) && 'Title') || + (props.query && isArtistQuery(props.query) && 'Artist') || "Unknown"; const handleQueryOnChange = (event: any) => { @@ -82,7 +82,7 @@ export default function FilterControl(props: IProps) { return {option} })} - {isTitleQuery(props.query) && } - {isArtistQuery(props.query) && } + {props.query && isTitleQuery(props.query) && } + {props.query && isArtistQuery(props.query) && } ; } \ No newline at end of file diff --git a/client/src/types/Query.tsx b/client/src/types/Query.tsx index 4396088..9b2836c 100644 --- a/client/src/types/Query.tsx +++ b/client/src/types/Query.tsx @@ -22,10 +22,18 @@ export function isArtistQuery(q: SongQuery): q is ArtistQuery { } export function ArtistToApiQuery(q: ArtistQuery) { return { + 'prop': SongQueryElemProperty.artistNames, + 'propOperand': '%' + q.artistLike + '%', + 'propOperator': SongQueryFilterOp.Like, } } export type SongQuery = TitleQuery | ArtistQuery; +export function isSongQuery(q: any): q is SongQuery { + return q != null && + (isTitleQuery(q) || isArtistQuery(q)); +} + export function toApiQuery(q: SongQuery) { return (isTitleQuery(q) && TitleToApiQuery(q)) || (isArtistQuery(q) && ArtistToApiQuery(q)) || {}; diff --git a/client/yarn.lock b/client/yarn.lock index df9b111..2f8175c 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -6736,6 +6736,11 @@ jss@^10.0.3, jss@^10.3.0: is-in-browser "^1.1.3" tiny-warning "^1.0.2" +jsurl@^0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/jsurl/-/jsurl-0.1.5.tgz#2a5c8741de39cacafc12f448908bf34e960dcee8" + integrity sha1-KlyHQd45ysr8EvRIkIvzTpYNzug= + jsx-ast-utils@^2.2.1, jsx-ast-utils@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-2.2.3.tgz#8a9364e402448a3ce7f14d357738310d9248054f" diff --git a/server/endpoints/QuerySongsEndpointHandler.ts b/server/endpoints/QuerySongsEndpointHandler.ts index 7cf7e09..f9e2b6b 100644 --- a/server/endpoints/QuerySongsEndpointHandler.ts +++ b/server/endpoints/QuerySongsEndpointHandler.ts @@ -16,8 +16,8 @@ const sequelizeOps: any = { const sequelizeProps: any = { [api.SongQueryElemProperty.title]: "title", [api.SongQueryElemProperty.id]: "id", - [api.SongQueryElemProperty.artistIds]: "$Artists.id$", - [api.SongQueryElemProperty.albumIds]: "$Albums.id$", + [api.SongQueryElemProperty.artistNames]: "$Artists.name$", + [api.SongQueryElemProperty.albumNames]: "$Albums.name$", }; // Returns the "where" clauses for Sequelize, per object type. @@ -57,21 +57,24 @@ export const QuerySongsEndpointHandler: EndpointHandler = async (req: any, res: const reqObject: api.QuerySongsRequest = req.body; try { + console.log('Song query:', reqObject.query, "where: ", getSequelizeWhere(reqObject.query)) const songs = await models.Song.findAll({ + // NOTE: have to disable limit and offset because of bug: https://github.com/sequelize/sequelize/issues/11938. + // Custom pagination is implemented before responding. where: getSequelizeWhere(reqObject.query), include: [models.Artist, models.Album, models.Tag], - limit: reqObject.limit, - offset: reqObject.offset, + //limit: reqObject.limit, + //offset: reqObject.offset, }) const response: api.QuerySongsResponse = { songs: await Promise.all(songs.map(async (song: any) => { - console.log("Song:", song, "artists:", song.getArtists()); const artists = await song.getArtists(); const tags = await song.getTags(); return { id: song.id, title: song.title, + storeLinks: song.storeLinks, artists: artists.map((artist: any) => { return { id: artist.id, @@ -83,9 +86,10 @@ export const QuerySongsEndpointHandler: EndpointHandler = async (req: any, res: id: tag.id, name: tag.name, } - }) + }), }; - })) + }).slice(reqObject.offset, reqObject.offset + reqObject.limit)) + // TODO: custom pagination due to bug mentioned above }; res.send(response); } catch (e) {