diff --git a/README.md b/README.md index 9976677..ed4638b 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,16 @@ MusicCatalogue.LookupTool --lookup "John Coltrane" "Blue Train" catalogue - Clicking on the artist name in any row in the track list or clicking on the "Back" button returns to the album list for that artist +#### Browsing By Genre + +- To browse by genre, click on the "Genres" menu item +- A page listing the genres derived from all albums in the //main catalogue// is displayed + +Genre List + +- As the mouse pointer is moved up and down the table, the current row is highlighted +- Clicking on a row opens the artist list for the genre shown in that row + #### The Wish List - To view the wish list, click on the "Wish List" menu item diff --git a/diagrams/artist-list.png b/diagrams/artist-list.png index bc7e8c6..a3a7d7b 100644 Binary files a/diagrams/artist-list.png and b/diagrams/artist-list.png differ diff --git a/diagrams/genre-list.png b/diagrams/genre-list.png new file mode 100644 index 0000000..0e8d4de Binary files /dev/null and b/diagrams/genre-list.png differ diff --git a/docker/api/Dockerfile b/docker/api/Dockerfile index 0966fa8..67582d1 100644 --- a/docker/api/Dockerfile +++ b/docker/api/Dockerfile @@ -1,4 +1,4 @@ FROM mcr.microsoft.com/dotnet/core/aspnet:latest -COPY musiccatalogue.api-1.17.0.0 /opt/musiccatalogue.api-1.17.0.0 -WORKDIR /opt/musiccatalogue.api-1.17.0.0/bin +COPY musiccatalogue.api-1.18.0.0 /opt/musiccatalogue.api-1.18.0.0 +WORKDIR /opt/musiccatalogue.api-1.18.0.0/bin ENTRYPOINT [ "./MusicCatalogue.Api" ] diff --git a/docker/ui/Dockerfile b/docker/ui/Dockerfile index aca9622..1fcf5c6 100644 --- a/docker/ui/Dockerfile +++ b/docker/ui/Dockerfile @@ -1,6 +1,6 @@ FROM node:20-alpine -COPY musiccatalogue.ui-1.17.0.0 /opt/musiccatalogue.ui-1.17.0.0 -WORKDIR /opt/musiccatalogue.ui-1.17.0.0 +COPY musiccatalogue.ui-1.18.0.0 /opt/musiccatalogue.ui-1.18.0.0 +WORKDIR /opt/musiccatalogue.ui-1.18.0.0 RUN npm install RUN npm run build ENTRYPOINT [ "npm", "start" ] diff --git a/src/MusicCatalogue.Api/Controllers/GenresController.cs b/src/MusicCatalogue.Api/Controllers/GenresController.cs index 8aac318..a3240af 100644 --- a/src/MusicCatalogue.Api/Controllers/GenresController.cs +++ b/src/MusicCatalogue.Api/Controllers/GenresController.cs @@ -3,6 +3,7 @@ using MusicCatalogue.Entities.Database; using MusicCatalogue.Entities.Exceptions; using MusicCatalogue.Entities.Interfaces; +using MusicCatalogue.Entities.Search; namespace MusicCatalogue.Api.Controllers { @@ -20,18 +21,22 @@ public GenresController(IMusicCatalogueFactory factory) } /// - /// Return a list of all the genres in the catalogue + /// Return a list of genres matching the specified criteria /// /// - [HttpGet] - [Route("")] - public async Task>> GetGenresAsync() + [HttpPost] + [Route("search")] + public async Task>> GetGenresAsync(GenreSearchCriteria criteria) { - // Get a list of all artists in the catalogue - List genres = await _factory.Genres.ListAsync(x => true); + // Ideally, this method would use the GET verb but as more filtering criteria are added that leads + // to an increasing number of query string parameters and a very messy URL. So the filter criteria + // are POSTed in the request body, instead, and bound into a strongly typed criteria object + + // Retrieve a list of matching genres + var genres = await _factory.Search.GenreSearchAsync(criteria); // If there are no genres, return a no content response - if (!genres.Any()) + if (genres == null) { return NoContent(); } diff --git a/src/MusicCatalogue.Api/MusicCatalogue.Api.csproj b/src/MusicCatalogue.Api/MusicCatalogue.Api.csproj index c88a9f5..e63ac14 100644 --- a/src/MusicCatalogue.Api/MusicCatalogue.Api.csproj +++ b/src/MusicCatalogue.Api/MusicCatalogue.Api.csproj @@ -2,9 +2,9 @@ net7.0 - 1.17.0.0 - 1.17.0.0 - 1.17.0 + 1.18.0.0 + 1.18.0.0 + 1.18.0 enable enable diff --git a/src/MusicCatalogue.Data/MusicCatalogue.Data.csproj b/src/MusicCatalogue.Data/MusicCatalogue.Data.csproj index b428021..bc01c2f 100644 --- a/src/MusicCatalogue.Data/MusicCatalogue.Data.csproj +++ b/src/MusicCatalogue.Data/MusicCatalogue.Data.csproj @@ -5,7 +5,7 @@ enable enable MusicCatalogue.Data - 1.16.0.0 + 1.17.0.0 Dave Walker Copyright (c) Dave Walker 2023 Dave Walker @@ -17,7 +17,7 @@ https://github.com/davewalker5/MusicCatalogue MIT false - 1.16.0.0 + 1.17.0.0 diff --git a/src/MusicCatalogue.Entities/Interfaces/ISearchManager.cs b/src/MusicCatalogue.Entities/Interfaces/ISearchManager.cs index cf5a709..8e1d7ea 100644 --- a/src/MusicCatalogue.Entities/Interfaces/ISearchManager.cs +++ b/src/MusicCatalogue.Entities/Interfaces/ISearchManager.cs @@ -7,5 +7,6 @@ public interface ISearchManager { Task?> AlbumSearchAsync(AlbumSearchCriteria criteria); Task?> ArtistSearchAsync(ArtistSearchCriteria criteria); + Task?> GenreSearchAsync(GenreSearchCriteria criteria); } } \ No newline at end of file diff --git a/src/MusicCatalogue.Entities/MusicCatalogue.Entities.csproj b/src/MusicCatalogue.Entities/MusicCatalogue.Entities.csproj index 927891c..2e91ad9 100644 --- a/src/MusicCatalogue.Entities/MusicCatalogue.Entities.csproj +++ b/src/MusicCatalogue.Entities/MusicCatalogue.Entities.csproj @@ -5,7 +5,7 @@ enable enable MusicCatalogue.Entities - 1.16.0.0 + 1.17.0.0 Dave Walker Copyright (c) Dave Walker 2023 Dave Walker @@ -17,7 +17,7 @@ https://github.com/davewalker5/MusicCatalogue MIT false - 1.16.0.0 + 1.17.0.0 diff --git a/src/MusicCatalogue.Entities/Search/GenreSearchCriteria.cs b/src/MusicCatalogue.Entities/Search/GenreSearchCriteria.cs new file mode 100644 index 0000000..d61bd02 --- /dev/null +++ b/src/MusicCatalogue.Entities/Search/GenreSearchCriteria.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; + +namespace MusicCatalogue.Entities.Search +{ + [ExcludeFromCodeCoverage] + public class GenreSearchCriteria + { + public bool? WishList { get; set; } + } +} diff --git a/src/MusicCatalogue.Logic/Database/SearchManager.cs b/src/MusicCatalogue.Logic/Database/SearchManager.cs index 520c501..996d9a9 100644 --- a/src/MusicCatalogue.Logic/Database/SearchManager.cs +++ b/src/MusicCatalogue.Logic/Database/SearchManager.cs @@ -1,7 +1,6 @@ using MusicCatalogue.Entities.Database; using MusicCatalogue.Entities.Interfaces; using MusicCatalogue.Entities.Search; -using System.Runtime.InteropServices; namespace MusicCatalogue.Logic.Database { @@ -30,29 +29,29 @@ internal SearchManager(IMusicCatalogueFactory factory) : base(factory) // as "return artists who have produced an album in the Jazz genre". So, start by retrieving a // list of albums matching the criteria then derive the artists from that var albums = await Factory.Albums - .ListAsync(x => ( + .ListAsync(x => ( (criteria.WishList == null) || ((criteria.WishList == false) && (x.IsWishListItem == null)) || (x.IsWishListItem == criteria.WishList) - ) && - ( + ) && + ( (criteria.GenreId == null) || (x.GenreId == criteria.GenreId) - )); + )); // If there are no albums, there can't be any matching artists if (albums.Any()) { - // Compile a list of artist IDs and load the matching artists - var artistIds = albums.Select(x => x.ArtistId).ToList(); + // Compile a list of unique artist IDs and load the matching artists + var artistIds = albums.Select(x => x.ArtistId).Distinct().ToList(); artists = await Factory.Artists - .ListAsync(x => artistIds.Contains(x.Id) && - ( + .ListAsync(x => artistIds.Contains(x.Id) && + ( (prefix == null) || ((x.SearchableName != null) && x.SearchableName.StartsWith(prefix)) || ((x.SearchableName == null) && x.Name.StartsWith(prefix)) - ), - false); + ), + false); // Now map the albums onto their associated artists foreach (var artist in artists) @@ -90,5 +89,32 @@ internal SearchManager(IMusicCatalogueFactory factory) : base(factory) return albums.Any() ? albums : null; } + /// + /// Return the genres matching the specified criteria + /// + /// + /// + public async Task?> GenreSearchAsync(GenreSearchCriteria criteria) + { + List? genres = null; + + // Retrieve a list of albums, as they're the entity that's tagged with a genre + var albums = await Factory.Albums + .ListAsync(x => ( + (criteria.WishList == null) || + ((criteria.WishList == false) && (x.IsWishListItem == null)) || + (x.IsWishListItem == criteria.WishList) + )); + + // If there are no albums, there can't be any matching artists + if (albums.Any()) + { + // Get a list of unique genre IDs and load the matching genres + var genreIds = albums.Select(x => x.GenreId).Distinct().ToList(); + genres = await Factory.Genres.ListAsync(x => genreIds.Contains(x.Id)); + } + + return genres; + } } } diff --git a/src/MusicCatalogue.Logic/MusicCatalogue.Logic.csproj b/src/MusicCatalogue.Logic/MusicCatalogue.Logic.csproj index bdfa29a..fe415bd 100644 --- a/src/MusicCatalogue.Logic/MusicCatalogue.Logic.csproj +++ b/src/MusicCatalogue.Logic/MusicCatalogue.Logic.csproj @@ -5,7 +5,7 @@ enable enable MusicCatalogue.Logic - 1.16.0.0 + 1.17.0.0 Dave Walker Copyright (c) Dave Walker 2023 Dave Walker @@ -17,7 +17,7 @@ https://github.com/davewalker5/MusicCatalogue MIT false - 1.16.0.0 + 1.17.0.0 diff --git a/src/MusicCatalogue.LookupTool/MusicCatalogue.LookupTool.csproj b/src/MusicCatalogue.LookupTool/MusicCatalogue.LookupTool.csproj index dd4ae0d..aa44384 100644 --- a/src/MusicCatalogue.LookupTool/MusicCatalogue.LookupTool.csproj +++ b/src/MusicCatalogue.LookupTool/MusicCatalogue.LookupTool.csproj @@ -3,9 +3,9 @@ Exe net7.0 - 1.16.0.0 - 1.16.0.0 - 1.16.0 + 1.17.0.0 + 1.17.0.0 + 1.17.0 enable enable false diff --git a/src/MusicCatalogue.Tests/AlbumSearchManagerTest.cs b/src/MusicCatalogue.Tests/AlbumSearchManagerTest.cs index a3ed1fa..119d756 100644 --- a/src/MusicCatalogue.Tests/AlbumSearchManagerTest.cs +++ b/src/MusicCatalogue.Tests/AlbumSearchManagerTest.cs @@ -29,7 +29,7 @@ public void TestInitialize() // Add the artists _jazzArtistId = Task.Run(() => _factory.Artists.AddAsync("Diana Krall")).Result.Id; - _popGenreId = Task.Run(() => _factory.Artists.AddAsync("Katie Melua")).Result.Id; + _popArtistId = Task.Run(() => _factory.Artists.AddAsync("Katie Melua")).Result.Id; // Add the albums, one on the wishlist and one not Task.Run(() => _factory.Albums.AddAsync(_jazzArtistId, _jazzGenreId, JazzAlbumTitle, 2002, null, false, null, null, null)).Wait(); diff --git a/src/MusicCatalogue.Tests/GenreSearchManagerTest.cs b/src/MusicCatalogue.Tests/GenreSearchManagerTest.cs new file mode 100644 index 0000000..1a48d35 --- /dev/null +++ b/src/MusicCatalogue.Tests/GenreSearchManagerTest.cs @@ -0,0 +1,82 @@ +using MusicCatalogue.Data; +using MusicCatalogue.Entities.Interfaces; +using MusicCatalogue.Entities.Search; +using MusicCatalogue.Logic.Factory; + +namespace MusicCatalogue.Tests +{ + [TestClass] + public class GenreSearchManagerTest + { + private const string JazzGenre = "Jazz"; + private const string PopGenre = "Pop"; + + private IMusicCatalogueFactory? _factory; + private int _jazzGenreId; + private int _popGenreId; + private int _jazzArtistId; + private int _popArtistId; + + [TestInitialize] + public void TestInitialize() + { + MusicCatalogueDbContext context = MusicCatalogueDbContextFactory.CreateInMemoryDbContext(); + _factory = new MusicCatalogueFactory(context); + + // Add the genres + _jazzGenreId = Task.Run(() => _factory.Genres.AddAsync("Jazz")).Result.Id; + _popGenreId = Task.Run(() => _factory.Genres.AddAsync("Pop")).Result.Id; + + // Add the artists + _jazzArtistId = Task.Run(() => _factory.Artists.AddAsync("Diana Krall")).Result.Id; + _popArtistId = Task.Run(() => _factory.Artists.AddAsync("Katie Melua")).Result.Id; + } + + [TestMethod] + public async Task SearchForAllGenresTest() + { + // Add the albums, one on the wishlist and one not + Task.Run(() => _factory!.Albums.AddAsync(_jazzArtistId, _jazzGenreId, "Live In Paris", 2002, null, false, null, null, null)).Wait(); + Task.Run(() => _factory!.Albums.AddAsync(_popArtistId, _popGenreId, "Album No. 8", 2020, null, true, null, null, null)).Wait(); + + var genres = await _factory!.Search.GenreSearchAsync(new GenreSearchCriteria()); + Assert.IsNotNull(genres); + Assert.AreEqual(2, genres.Count); + } + + [TestMethod] + public async Task SearchMainCatalogueGenresTest() + { + // Add the albums, one on the wishlist and one not + Task.Run(() => _factory!.Albums.AddAsync(_jazzArtistId, _jazzGenreId, "Live In Paris", 2002, null, false, null, null, null)).Wait(); + Task.Run(() => _factory!.Albums.AddAsync(_popArtistId, _popGenreId, "Album No. 8", 2020, null, true, null, null, null)).Wait(); + + var criteria = new GenreSearchCriteria { WishList = false }; + var genres = await _factory!.Search.GenreSearchAsync(criteria); + Assert.IsNotNull(genres); + Assert.AreEqual(1, genres.Count); + Assert.AreEqual(JazzGenre, genres[0].Name); + } + + [TestMethod] + public async Task SearchWishlistGenresTest() + { + // Add the albums, one on the wishlist and one not + Task.Run(() => _factory!.Albums.AddAsync(_jazzArtistId, _jazzGenreId, "Live In Paris", 2002, null, false, null, null, null)).Wait(); + Task.Run(() => _factory!.Albums.AddAsync(_popArtistId, _popGenreId, "Album No. 8", 2020, null, true, null, null, null)).Wait(); + + var criteria = new GenreSearchCriteria { WishList = true }; + var genres = await _factory!.Search.GenreSearchAsync(criteria); + Assert.IsNotNull(genres); + Assert.AreEqual(1, genres.Count); + Assert.AreEqual(PopGenre, genres[0].Name); + } + + [TestMethod] + public async Task NoMatchesTest() + { + var genres = await _factory!.Search.GenreSearchAsync(new GenreSearchCriteria()); + Assert.IsNull(genres); + } + } +} diff --git a/src/music-catalogue-ui/components/albumPurchaseDetails.js b/src/music-catalogue-ui/components/albumPurchaseDetails.js index c79ce59..563c3cd 100644 --- a/src/music-catalogue-ui/components/albumPurchaseDetails.js +++ b/src/music-catalogue-ui/components/albumPurchaseDetails.js @@ -64,12 +64,12 @@ const AlbumPurchaseDetails = ({ artist, album, navigate, logout }) => { // If the returned album is valid, navigate back to the albums by artist page. // Otherwise, show an error if (updatedAlbum != null) { - navigate( - pages.albums, - artist, - updatedAlbum, - updatedAlbum.isWishListItem - ); + navigate({ + page: pages.albums, + artist: artist, + album: updatedAlbum, + isWishList: updatedAlbum.isWishListItem, + }); } else { setErrorMessage("Error updating the album purchase details"); } @@ -151,7 +151,12 @@ const AlbumPurchaseDetails = ({ artist, album, navigate, logout }) => { - navigate(pages.export, null, null, false)}>Export + navigate({ page: pages.export })}>Export Import - navigate(pages.lookup, null, null, false)}>Search - navigate(pages.artists, null, null, true)}> + navigate({ page: pages.lookup })}>Search + + navigate({ page: pages.artists, filter: "A", isWishList: true }) + } + > Wish List - navigate(pages.artists, null, null, false)}> + navigate({ page: pages.genres })}>Genres + navigate({ page: pages.artists, filter: "A" })}> Artists diff --git a/src/music-catalogue-ui/components/trackList.js b/src/music-catalogue-ui/components/trackList.js index 65d6fda..b2f5c0e 100644 --- a/src/music-catalogue-ui/components/trackList.js +++ b/src/music-catalogue-ui/components/trackList.js @@ -27,7 +27,7 @@ const TrackList = ({ artist, album, isWishList, navigate, logout }) => { // Backwards navigation callback const navigateBack = useCallback(() => { - navigate(pages.albums, artist, null, isWishList); + navigate({ page: pages.albums, artist: artist, isWishList: isWishList }); }, [navigate, artist, isWishList]); return ( diff --git a/src/music-catalogue-ui/components/trackRow.js b/src/music-catalogue-ui/components/trackRow.js index f0e8d5b..e4d0e96 100644 --- a/src/music-catalogue-ui/components/trackRow.js +++ b/src/music-catalogue-ui/components/trackRow.js @@ -12,7 +12,13 @@ const TrackRow = ({ artist, album, track, isWishList, navigate }) => { {album.title} navigate(pages.albums, artist, null, isWishList)} + onClick={() => + navigate({ + page: pages.albums, + artist: artist, + isWishList: isWishList, + }) + } > {artist.name} diff --git a/src/music-catalogue-ui/helpers/apiArtists.js b/src/music-catalogue-ui/helpers/apiArtists.js index 6761938..8ff85f9 100644 --- a/src/music-catalogue-ui/helpers/apiArtists.js +++ b/src/music-catalogue-ui/helpers/apiArtists.js @@ -5,16 +5,17 @@ import { apiGetHeaders, apiGetPostHeaders } from "./apiHeaders"; /** * Fetch a list of artists from the Music Catalogue REST API * @param {*} filter + * @param {*} genreId * @param {*} isWishList * @param {*} logout * @returns */ -const apiFetchArtists = async (filter, isWishList, logout) => { +const apiFetchArtists = async (filter, genreId, isWishList, logout) => { // Construct the filtering criteria as the request body and convert to JSON const criteria = { namePrefix: filter, wishList: isWishList, - genreId: null, + genreId: genreId, }; const body = JSON.stringify(criteria); diff --git a/src/music-catalogue-ui/helpers/apiGenres.js b/src/music-catalogue-ui/helpers/apiGenres.js new file mode 100644 index 0000000..928d1b5 --- /dev/null +++ b/src/music-catalogue-ui/helpers/apiGenres.js @@ -0,0 +1,30 @@ +import config from "../config.json"; +import { apiReadResponseData } from "./apiUtils"; +import { apiGetPostHeaders } from "./apiHeaders"; + +/** + * Fetch a list of genres from the Music Catalogue REST API + * @param {*} isWishList + * @param {*} logout + * @returns + */ +const apiFetchGenres = async (isWishList, logout) => { + // Construct the filtering criteria as the request body and convert to JSON + const criteria = { + wishList: isWishList, + }; + const body = JSON.stringify(criteria); + + // Call the API to get a list of genres + const url = `${config.api.baseUrl}/genres/search/`; + const response = await fetch(url, { + method: "POST", + headers: apiGetPostHeaders(), + body: body, + }); + + const genres = await apiReadResponseData(response, logout); + return genres; +}; + +export { apiFetchGenres }; diff --git a/src/music-catalogue-ui/helpers/navigation.js b/src/music-catalogue-ui/helpers/navigation.js index 9dfc460..c91118c 100644 --- a/src/music-catalogue-ui/helpers/navigation.js +++ b/src/music-catalogue-ui/helpers/navigation.js @@ -1,5 +1,6 @@ const pages = { artists: "Artists", + genres: "Genres", wishlistArtists: "WishlistArtists", albums: "Albums", wishlistAlbums: "wishlistAlbums", diff --git a/src/music-catalogue-ui/hooks/useAlbums.js b/src/music-catalogue-ui/hooks/useAlbums.js index fb61967..a1a36c8 100644 --- a/src/music-catalogue-ui/hooks/useAlbums.js +++ b/src/music-catalogue-ui/hooks/useAlbums.js @@ -16,8 +16,7 @@ const useAlbums = (artistId, isWishList, logout) => { useEffect(() => { const fetchAlbums = async (artistId) => { try { - // Get a list of albums via the service, store it in state and clear the - // loading status + // Get a list of albums via the service and store it in state var fetchedAlbums = await apiFetchAlbumsByArtist( artistId, isWishList, diff --git a/src/music-catalogue-ui/hooks/useArtists.js b/src/music-catalogue-ui/hooks/useArtists.js index e68b725..77cafbf 100644 --- a/src/music-catalogue-ui/hooks/useArtists.js +++ b/src/music-catalogue-ui/hooks/useArtists.js @@ -8,16 +8,23 @@ import { apiFetchArtists } from "@/helpers/apiArtists"; * @param {*} logout * @returns */ -const useArtists = (filter, isWishlist, logout) => { +const useArtists = (filter, genre, isWishlist, logout) => { // Current list of artists and the method to change it const [artists, setArtists] = useState([]); useEffect(() => { const fetchArtists = async () => { try { - // Get a list of artists via the service, store it in state and clear the - // loading status - var fetchedArtists = await apiFetchArtists(filter, isWishlist, logout); + // Get the genre Id + const genreId = genre != null ? genre.id : null; + + // Get a list of artists via the service and store it in state + var fetchedArtists = await apiFetchArtists( + filter, + genreId, + isWishlist, + logout + ); setArtists(fetchedArtists); } catch {} }; diff --git a/src/music-catalogue-ui/hooks/useGenres.js b/src/music-catalogue-ui/hooks/useGenres.js new file mode 100644 index 0000000..a77ff66 --- /dev/null +++ b/src/music-catalogue-ui/hooks/useGenres.js @@ -0,0 +1,29 @@ +import { useState, useEffect } from "react"; +import { apiFetchGenres } from "@/helpers/apiGenres"; + +/** + * Hook that uses the API helpers to retrieve a list of genres from the + * Music Catalogue REST API + * @param {*} logout + * @returns + */ +const useGenres = (logout) => { + // Current list of artists and the method to change it + const [genres, setGenres] = useState([]); + + useEffect(() => { + const fetchGenres = async () => { + try { + // Get a list of genres via the service and store it in state + var fetchedGenres = await apiFetchGenres(logout); + setGenres(fetchedGenres); + } catch {} + }; + + fetchGenres(); + }, [logout]); + + return { genres, setGenres }; +}; + +export default useGenres;