diff --git a/src/components/PlaylistTable.test.tsx b/src/components/PlaylistTable.test.tsx index 5d0ff62d..2a0a6a66 100644 --- a/src/components/PlaylistTable.test.tsx +++ b/src/components/PlaylistTable.test.tsx @@ -9,7 +9,7 @@ import JSZip from "jszip" import PlaylistTable from "./PlaylistTable" import "../icons" -import { handlerCalled, handlers, nullAlbumHandlers, nullTrackHandlers, localTrackHandlers, duplicateTrackHandlers, missingPlaylistsHandlers } from "../mocks/handlers" +import { handlerCalled, handlers, nullAlbumHandlers, nullTrackHandlers, localTrackHandlers, duplicateTrackHandlers, missingPlaylistsHandlers, multipleFilterPlaylistsHandlers } from "../mocks/handlers" const server = setupServer(...handlers) @@ -487,6 +487,40 @@ describe("searching playlists", () => { expect(screen.queryByText("Ghostpoet – Peanut Butter Blues and Melancholy Jam")).not.toBeInTheDocument() }) }) + + test("multiple filters can be combined", async () => { + server.use(...multipleFilterPlaylistsHandlers) + + render() + + expect(await screen.findByRole('searchbox')).toBeInTheDocument() + + userEvent.type(screen.getByRole('searchbox'), 'public:true owner:otheruser{enter}') + + await waitFor(() => { + expect(screen.queryAllByRole('row')).toHaveLength(2) + expect(screen.queryByText("Public Playlist")).toBeInTheDocument() + expect(screen.queryByText("Ghostpoet – Peanut Butter Blues and Melancholy Jam")).not.toBeInTheDocument() + expect(screen.queryByText("Liked")).not.toBeInTheDocument() + }) + }) + + test("filters can be combined with a name search", async () => { + server.use(...multipleFilterPlaylistsHandlers) + + render() + + expect(await screen.findByRole('searchbox')).toBeInTheDocument() + + userEvent.type(screen.getByRole('searchbox'), 'public:false Ghost{enter}') + + await waitFor(() => { + expect(screen.queryAllByRole('row')).toHaveLength(2) + expect(screen.queryByText("Ghostpoet – Peanut Butter Blues and Melancholy Jam")).toBeInTheDocument() + expect(screen.queryByText("Public Playlist")).not.toBeInTheDocument() + expect(screen.queryByText("Liked")).not.toBeInTheDocument() + }) + }) }) describe("missing playlists", () => { diff --git a/src/components/PlaylistTable.tsx b/src/components/PlaylistTable.tsx index 0fe3fd5e..1401bd32 100644 --- a/src/components/PlaylistTable.tsx +++ b/src/components/PlaylistTable.tsx @@ -74,7 +74,7 @@ class PlaylistTable extends React.Component { }) let key = "subtitle_search" - if (query.startsWith("public:") || query.startsWith("collaborative:") || query.startsWith("owner:")) { + if (/\b(public|collaborative|owner):/.test(query)) { key += "_advanced" } diff --git a/src/components/TopMenu.tsx b/src/components/TopMenu.tsx index 0b678506..38486412 100644 --- a/src/components/TopMenu.tsx +++ b/src/components/TopMenu.tsx @@ -102,6 +102,10 @@ class TopMenu extends React.Component { owner:[owner] }} /> + + public:true collaborative:false + }} /> + diff --git a/src/components/data/PlaylistsData.ts b/src/components/data/PlaylistsData.ts index 533f3764..990d3bb4 100644 --- a/src/components/data/PlaylistsData.ts +++ b/src/components/data/PlaylistsData.ts @@ -56,20 +56,47 @@ class PlaylistsData { // Remove any uninitialized playlists when exporting let results = this.data.filter(p => p && Object.keys(p).length > 0) - if (query.startsWith("public:")) { - return results.filter(p => p.public === query.endsWith(":true")) - } else if (query.startsWith("collaborative:")) { - return results.filter(p => p.collaborative === query.endsWith(":true")) - } else if (query.startsWith("owner:")) { - let owner = query.match(/owner:(.*)/)?.at(-1)?.toLowerCase() - if (owner === "me") owner = this.userId - - return results.filter(p => p.owner).filter(p => p.owner.id === owner) - } else { + const { filters, textQuery } = this.parseQuery(query) + + filters.forEach(filter => { + if (filter.key === "public") { + results = results.filter(p => p.public === filter.value) + } else if (filter.key === "collaborative") { + results = results.filter(p => p.collaborative === filter.value) + } else if (filter.key === "owner") { + results = results.filter(p => p.owner && p.owner.id === filter.value) + } + }) + + if (textQuery) { // Case-insensitive search in playlist name // TODO: Add lazy evaluation for performance? - return results.filter(p => p.name.toLowerCase().includes(query.toLowerCase())) + results = results.filter(p => p.name.toLowerCase().includes(textQuery.toLowerCase())) } + + return results + } + + private parseQuery(query: string) { + const tokens = query.trim() ? query.trim().split(/\s+/) : [] + const filters: { key: string, value: any }[] = [] + const textTokens: string[] = [] + + tokens.forEach(token => { + if (/^public:(true|false)$/.test(token)) { + filters.push({ key: "public", value: token.endsWith(":true") }) + } else if (/^collaborative:(true|false)$/.test(token)) { + filters.push({ key: "collaborative", value: token.endsWith(":true") }) + } else if (/^owner:(.+)$/.test(token)) { + let owner = token.match(/^owner:(.+)$/)![1].toLowerCase() + if (owner === "me") owner = this.userId + filters.push({ key: "owner", value: owner }) + } else { + textTokens.push(token) + } + }) + + return { filters, textQuery: textTokens.join(" ") } } async loadAll() { diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 0214958e..f021e1ab 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -32,6 +32,7 @@ "collaborative_false": "Don't show collaborative playlists", "owner_me": "Only show playlists I own", "owner_owner": "Only show playlists owned by [owner]", + "multiple_filters": "Combine multiple filters and/or a name search", "more_detail": "For more detail please refer to the full project documentation." } }, diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index d7708050..3187b8bb 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -1228,6 +1228,90 @@ export const localTrackHandlers = [ }) ] +export const multipleFilterPlaylistsHandlers = [ + rest.get('https://api.spotify.com/v1/users/watsonbox/playlists', (req, res, ctx) => { + handlerCalled(req.url.toString()) + + if (req.headers.get("Authorization") !== "Bearer TEST_ACCESS_TOKEN") { + return res(ctx.status(401), ctx.json({ message: 'Not authorized' })) + } + + return res(ctx.json( + { + "href": "https://api.spotify.com/v1/users/watsonbox/playlists?offset=0&limit=20", + "items": [{ + "collaborative": false, + "description": "", + "external_urls": { + "spotify": "https://open.spotify.com/playlist/4XOGDpHMrVoH33uJEwHWU5" + }, + "href": "https://api.spotify.com/v1/playlists/4XOGDpHMrVoH33uJEwHWU5", + "id": "4XOGDpHMrVoH33uJEwHWU5", + "images": [{ + "height": 640, + "url": "https://i.scdn.co/image/ab67616d0000b273306e7640be17c5b3468e6e80", + "width": 640 + }], + "name": "Ghostpoet – Peanut Butter Blues and Melancholy Jam", + "owner": { + "display_name": "watsonbox", + "external_urls": { + "spotify": "https://open.spotify.com/user/watsonbox" + }, + "href": "https://api.spotify.com/v1/users/watsonbox", + "id": "watsonbox", + "type": "user", + "uri": "spotify:user:watsonbox" + }, + "primary_color": null, + "public": false, + "snapshot_id": "MixjMzFkNjFhYzJkMDkzNmE3OGQ1N2YyYmQ0NTkxYTk5NjBhZmZkYzZi", + "tracks": { + "href": "https://api.spotify.com/v1/playlists/4XOGDpHMrVoH33uJEwHWU5/tracks", + "total": 10 + }, + "type": "playlist", + "uri": "spotify:playlist:4XOGDpHMrVoH33uJEwHWU5" + }, { + "collaborative": false, + "description": "", + "external_urls": { + "spotify": "https://open.spotify.com/playlist/PUBLICPLAYLIST" + }, + "href": "https://api.spotify.com/v1/playlists/PUBLICPLAYLIST", + "id": "PUBLICPLAYLIST", + "images": [], + "name": "Public Playlist", + "owner": { + "display_name": "otheruser", + "external_urls": { + "spotify": "https://open.spotify.com/user/otheruser" + }, + "href": "https://api.spotify.com/v1/users/otheruser", + "id": "otheruser", + "type": "user", + "uri": "spotify:user:otheruser" + }, + "primary_color": null, + "public": true, + "snapshot_id": "public", + "tracks": { + "href": "https://api.spotify.com/v1/playlists/PUBLICPLAYLIST/tracks", + "total": 5 + }, + "type": "playlist", + "uri": "spotify:playlist:PUBLICPLAYLIST" + }], + "limit": 20, + "next": null, + "offset": 0, + "previous": null, + "total": 2 + } + )) + }), +] + export const duplicateTrackHandlers = [ rest.get('https://api.spotify.com/v1/playlists/4XOGDpHMrVoH33uJEwHWU5/tracks', (req, res, ctx) => { handlerCalled(req.url.toString())