diff --git a/README.md b/README.md index 40cd2d4..a36c90f 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,8 @@ MusicCatalogue.LookupTool --lookup "John Coltrane" "Blue Train" Album List - As the mouse pointer is moved up and down the table, the current row is highlighted -- Clicking on a row opens the track list for the album shown in that row: +- Clicking on the trash icon prompts for confirmation and, if confirmed, deletes the album shown in that row along with the associated tracks +- Clicking anywhere else on a row opens the track list for the album shown in that row: Track List diff --git a/diagrams/album-list.png b/diagrams/album-list.png index b6b0a62..8d94eae 100644 Binary files a/diagrams/album-list.png and b/diagrams/album-list.png differ diff --git a/docker/api/Dockerfile b/docker/api/Dockerfile index 13e371f..c2c2f73 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.7.0.0 /opt/musiccatalogue.api-1.7.0.0 -WORKDIR /opt/musiccatalogue.api-1.7.0.0/bin +COPY musiccatalogue.api-1.8.0.0 /opt/musiccatalogue.api-1.8.0.0 +WORKDIR /opt/musiccatalogue.api-1.8.0.0/bin ENTRYPOINT [ "./MusicCatalogue.Api" ] diff --git a/docker/ui/Dockerfile b/docker/ui/Dockerfile index d196cb6..398bde2 100644 --- a/docker/ui/Dockerfile +++ b/docker/ui/Dockerfile @@ -1,6 +1,6 @@ FROM node:20-alpine -COPY musiccatalogue.ui-1.7.0.0 /opt/musiccatalogue.ui-1.7.0.0 -WORKDIR /opt/musiccatalogue.ui-1.7.0.0 +COPY musiccatalogue.ui-1.8.0.0 /opt/musiccatalogue.ui-1.8.0.0 +WORKDIR /opt/musiccatalogue.ui-1.8.0.0 RUN npm install RUN npm run build ENTRYPOINT [ "npm", "start" ] diff --git a/src/MusicCatalogue.Api/Controllers/AlbumsController.cs b/src/MusicCatalogue.Api/Controllers/AlbumsController.cs index 28c3d3b..7e6cba8 100644 --- a/src/MusicCatalogue.Api/Controllers/AlbumsController.cs +++ b/src/MusicCatalogue.Api/Controllers/AlbumsController.cs @@ -45,5 +45,21 @@ public async Task>> GetAlbumsByArtistAsync(int a return albums; } + + [HttpDelete] + [Route("{id}")] + public async Task DeleteAlbum(int id) + { + // Check the album exists, first + var album = await _factory.Albums.GetAsync(x => x.Id == id); + if (album == null) + { + return NotFound(); + } + + // It does, so delete it + await _factory.Albums.DeleteAsync(id); + return Ok(); + } } } diff --git a/src/MusicCatalogue.Api/MusicCatalogue.Api.csproj b/src/MusicCatalogue.Api/MusicCatalogue.Api.csproj index 638532d..e95904c 100644 --- a/src/MusicCatalogue.Api/MusicCatalogue.Api.csproj +++ b/src/MusicCatalogue.Api/MusicCatalogue.Api.csproj @@ -2,9 +2,9 @@ net7.0 - 1.7.0.0 - 1.7.0.0 - 1.7.0 + 1.8.0.0 + 1.8.0.0 + 1.8.0 enable enable diff --git a/src/MusicCatalogue.Data/MusicCatalogue.Data.csproj b/src/MusicCatalogue.Data/MusicCatalogue.Data.csproj index ac31426..7ac8732 100644 --- a/src/MusicCatalogue.Data/MusicCatalogue.Data.csproj +++ b/src/MusicCatalogue.Data/MusicCatalogue.Data.csproj @@ -5,7 +5,7 @@ enable enable MusicCatalogue.Data - 1.6.0.0 + 1.7.0.0 Dave Walker Copyright (c) Dave Walker 2023 Dave Walker @@ -17,7 +17,7 @@ https://github.com/davewalker5/MusicCatalogue MIT false - 1.6.0.0 + 1.7.0.0 diff --git a/src/MusicCatalogue.Entities/Database/TrackBase.cs b/src/MusicCatalogue.Entities/Database/TrackBase.cs index fcefea8..905c0cd 100644 --- a/src/MusicCatalogue.Entities/Database/TrackBase.cs +++ b/src/MusicCatalogue.Entities/Database/TrackBase.cs @@ -1,5 +1,8 @@ -namespace MusicCatalogue.Entities.Database +using System.Diagnostics.CodeAnalysis; + +namespace MusicCatalogue.Entities.Database { + [ExcludeFromCodeCoverage] public abstract class TrackBase { public int? Duration { get; set; } diff --git a/src/MusicCatalogue.Entities/Interfaces/IAlbumManager.cs b/src/MusicCatalogue.Entities/Interfaces/IAlbumManager.cs index 2ef8f1f..e16279d 100644 --- a/src/MusicCatalogue.Entities/Interfaces/IAlbumManager.cs +++ b/src/MusicCatalogue.Entities/Interfaces/IAlbumManager.cs @@ -8,5 +8,6 @@ public interface IAlbumManager Task AddAsync(int artistId, string title, int? released, string? genre, string? coverUrl); Task GetAsync(Expression> predicate); Task> ListAsync(Expression> predicate); + Task DeleteAsync(int albumId); } } \ No newline at end of file diff --git a/src/MusicCatalogue.Entities/Interfaces/IMusicCatalogueFactory.cs b/src/MusicCatalogue.Entities/Interfaces/IMusicCatalogueFactory.cs index 1c6103e..d521a45 100644 --- a/src/MusicCatalogue.Entities/Interfaces/IMusicCatalogueFactory.cs +++ b/src/MusicCatalogue.Entities/Interfaces/IMusicCatalogueFactory.cs @@ -1,7 +1,10 @@ -namespace MusicCatalogue.Entities.Interfaces +using Microsoft.EntityFrameworkCore; + +namespace MusicCatalogue.Entities.Interfaces { public interface IMusicCatalogueFactory { + DbContext Context { get; } IAlbumManager Albums { get; } IArtistManager Artists { get; } ITrackManager Tracks { get; } diff --git a/src/MusicCatalogue.Entities/Interfaces/ITrackManager.cs b/src/MusicCatalogue.Entities/Interfaces/ITrackManager.cs index 3fa5add..66eb211 100644 --- a/src/MusicCatalogue.Entities/Interfaces/ITrackManager.cs +++ b/src/MusicCatalogue.Entities/Interfaces/ITrackManager.cs @@ -8,5 +8,6 @@ public interface ITrackManager Task AddAsync(int albumId, string title, int? number, int? duration); Task GetAsync(Expression> predicate); Task> ListAsync(Expression> predicate); + Task DeleteAsync(int albumId); } } \ No newline at end of file diff --git a/src/MusicCatalogue.Entities/MusicCatalogue.Entities.csproj b/src/MusicCatalogue.Entities/MusicCatalogue.Entities.csproj index 54679db..1d9edd0 100644 --- a/src/MusicCatalogue.Entities/MusicCatalogue.Entities.csproj +++ b/src/MusicCatalogue.Entities/MusicCatalogue.Entities.csproj @@ -5,7 +5,7 @@ enable enable MusicCatalogue.Entities - 1.6.0.0 + 1.7.0.0 Dave Walker Copyright (c) Dave Walker 2023 Dave Walker @@ -17,7 +17,11 @@ https://github.com/davewalker5/MusicCatalogue MIT false - 1.6.0.0 + 1.7.0.0 + + + + diff --git a/src/MusicCatalogue.Logic/Database/AlbumManager.cs b/src/MusicCatalogue.Logic/Database/AlbumManager.cs index 69e4808..72af04a 100644 --- a/src/MusicCatalogue.Logic/Database/AlbumManager.cs +++ b/src/MusicCatalogue.Logic/Database/AlbumManager.cs @@ -1,15 +1,21 @@ using Microsoft.EntityFrameworkCore; using MusicCatalogue.Data; -using MusicCatalogue.Entities.Interfaces; using MusicCatalogue.Entities.Database; +using MusicCatalogue.Entities.Interfaces; +using MusicCatalogue.Logic.Factory; using System.Linq.Expressions; namespace MusicCatalogue.Logic.Database { - public class AlbumManager : DatabaseManagerBase, IAlbumManager + public class AlbumManager : IAlbumManager { - internal AlbumManager(MusicCatalogueDbContext context) : base(context) + private readonly MusicCatalogueFactory _factory; + private readonly MusicCatalogueDbContext? _context; + + internal AlbumManager(MusicCatalogueFactory factory) { + _factory = factory; + _context = factory.Context as MusicCatalogueDbContext; } /// @@ -32,11 +38,11 @@ public async Task GetAsync(Expression> predicate) /// /// public async Task> ListAsync(Expression> predicate) - => await _context.Albums - .Where(predicate) - .OrderBy(x => x.Title) - .Include(x => x.Tracks) - .ToListAsync(); + => await _context!.Albums + .Where(predicate) + .OrderBy(x => x.Title) + .Include(x => x.Tracks) + .ToListAsync(); /// /// Add an album, if it doesn't already exist @@ -62,11 +68,31 @@ public async Task AddAsync(int artistId, string title, int? released, str Genre = StringCleaner.RemoveInvalidCharacters(genre), CoverUrl = StringCleaner.RemoveInvalidCharacters(coverUrl) }; - await _context.Albums.AddAsync(album); + await _context!.Albums.AddAsync(album); await _context.SaveChangesAsync(); } return album; } + + /// + /// Delete the album with the specified Id + /// + /// + /// + public async Task DeleteAsync(int albumId) + { + // Find the album record and check it exists + Album album = await GetAsync(x => x.Id == albumId); + if (album != null) + { + // Delete the associated tracks + await _factory.Tracks.DeleteAsync(albumId); + + // Delete the album record and save changes + _factory.Context.Remove(album); + await _factory.Context.SaveChangesAsync(); + } + } } } \ No newline at end of file diff --git a/src/MusicCatalogue.Logic/Database/TrackManager.cs b/src/MusicCatalogue.Logic/Database/TrackManager.cs index 7baed28..cdca4c5 100644 --- a/src/MusicCatalogue.Logic/Database/TrackManager.cs +++ b/src/MusicCatalogue.Logic/Database/TrackManager.cs @@ -65,5 +65,20 @@ public async Task AddAsync(int albumId, string title, int? number, int? d return track; } + + /// + /// Delete the tracks associated with an album, given its ID + /// + /// + /// + public async Task DeleteAsync(int albumId) + { + List tracks = await ListAsync(x => x.AlbumId == albumId); + if (tracks.Any()) + { + _context.Tracks.RemoveRange(tracks); + await _context.SaveChangesAsync(); + } + } } } \ No newline at end of file diff --git a/src/MusicCatalogue.Logic/Factory/MusicCatalogueFactory.cs b/src/MusicCatalogue.Logic/Factory/MusicCatalogueFactory.cs index a0138e1..da3d7c4 100644 --- a/src/MusicCatalogue.Logic/Factory/MusicCatalogueFactory.cs +++ b/src/MusicCatalogue.Logic/Factory/MusicCatalogueFactory.cs @@ -1,4 +1,5 @@ -using MusicCatalogue.Data; +using Microsoft.EntityFrameworkCore; +using MusicCatalogue.Data; using MusicCatalogue.Entities.Interfaces; using MusicCatalogue.Logic.Database; using MusicCatalogue.Logic.DataExchange; @@ -18,6 +19,7 @@ public class MusicCatalogueFactory : IMusicCatalogueFactory private readonly Lazy _statistics; private readonly Lazy _jobStatuses; + public DbContext Context { get; private set; } public IArtistManager Artists { get { return _artists.Value; } } public IAlbumManager Albums { get { return _albums.Value; } } public ITrackManager Tracks { get { return _tracks.Value; } } @@ -36,8 +38,9 @@ public class MusicCatalogueFactory : IMusicCatalogueFactory public MusicCatalogueFactory(MusicCatalogueDbContext context) { + Context = context; _artists = new Lazy(() => new ArtistManager(context)); - _albums = new Lazy(() => new AlbumManager(context)); + _albums = new Lazy(() => new AlbumManager(this)); _tracks = new Lazy(() => new TrackManager(context)); _jobStatuses = new Lazy(() => new JobStatusManager(context)); _users = new Lazy(() => new UserManager(context)); diff --git a/src/MusicCatalogue.Logic/MusicCatalogue.Logic.csproj b/src/MusicCatalogue.Logic/MusicCatalogue.Logic.csproj index dbfe041..2f0a10e 100644 --- a/src/MusicCatalogue.Logic/MusicCatalogue.Logic.csproj +++ b/src/MusicCatalogue.Logic/MusicCatalogue.Logic.csproj @@ -5,7 +5,7 @@ enable enable MusicCatalogue.Logic - 1.6.0.0 + 1.7.0.0 Dave Walker Copyright (c) Dave Walker 2023 Dave Walker @@ -17,7 +17,7 @@ https://github.com/davewalker5/MusicCatalogue MIT false - 1.6.0.0 + 1.7.0.0 diff --git a/src/MusicCatalogue.LookupTool/MusicCatalogue.LookupTool.csproj b/src/MusicCatalogue.LookupTool/MusicCatalogue.LookupTool.csproj index b1c4c0f..5785b3a 100644 --- a/src/MusicCatalogue.LookupTool/MusicCatalogue.LookupTool.csproj +++ b/src/MusicCatalogue.LookupTool/MusicCatalogue.LookupTool.csproj @@ -3,9 +3,9 @@ Exe net7.0 - 1.6.0.0 - 1.6.0.0 - 1.6.0 + 1.7.0.0 + 1.7.0.0 + 1.7.0 enable enable diff --git a/src/MusicCatalogue.Tests/AlbumManagerTest.cs b/src/MusicCatalogue.Tests/AlbumManagerTest.cs index 68b426a..3149cb7 100644 --- a/src/MusicCatalogue.Tests/AlbumManagerTest.cs +++ b/src/MusicCatalogue.Tests/AlbumManagerTest.cs @@ -12,34 +12,38 @@ public class AlbumManagerTest private const int Released = 1957; private const string Genre = "Jazz"; private const string CoverUrl = "https://some.host/blue-train.jpg"; + private const string TrackTitle = "Blue Train"; + private const int TrackNumber = 1; + private const int TrackDuration = 643200; - private IAlbumManager? _manager = null; + private IMusicCatalogueFactory? _factory; private int _artistId; + private int _albumId; [TestInitialize] public void TestInitialize() { MusicCatalogueDbContext context = MusicCatalogueDbContextFactory.CreateInMemoryDbContext(); - var factory = new MusicCatalogueFactory(context); - _manager = factory.Albums; + _factory = new MusicCatalogueFactory(context); // Add an artist to the database - _artistId = Task.Run(() => factory.Artists.AddAsync(ArtistName)).Result.Id; - Task.Run(() => _manager.AddAsync(_artistId, AlbumTitle, Released, Genre, CoverUrl)).Wait(); + _artistId = Task.Run(() => _factory.Artists.AddAsync(ArtistName)).Result.Id; + _albumId = Task.Run(() => _factory.Albums.AddAsync(_artistId, AlbumTitle, Released, Genre, CoverUrl)).Result.Id; + Task.Run(() => _factory.Tracks.AddAsync(_albumId, TrackTitle, TrackNumber, TrackDuration)).Wait(); } [TestMethod] public async Task AddDuplicateTest() { - await _manager!.AddAsync(_artistId, AlbumTitle, Released, Genre, CoverUrl); - var albums = await _manager.ListAsync(x => true); + await _factory!.Albums.AddAsync(_artistId, AlbumTitle, Released, Genre, CoverUrl); + var albums = await _factory!.Albums.ListAsync(x => true); Assert.AreEqual(1, albums.Count); } [TestMethod] public async Task AddAndGetTest() { - var album = await _manager!.GetAsync(a => a.Title == AlbumTitle); + var album = await _factory!.Albums.GetAsync(a => a.Title == AlbumTitle); Assert.IsNotNull(album); Assert.IsTrue(album.Id > 0); Assert.AreEqual(_artistId, album.ArtistId); @@ -52,14 +56,14 @@ public async Task AddAndGetTest() [TestMethod] public async Task GetMissingTest() { - var album = await _manager!.GetAsync(a => a.Title == "Missing"); + var album = await _factory!.Albums.GetAsync(a => a.Title == "Missing"); Assert.IsNull(album); } [TestMethod] public async Task ListAllTest() { - var albums = await _manager!.ListAsync(x => true); + var albums = await _factory!.Albums.ListAsync(x => true); Assert.AreEqual(1, albums!.Count); Assert.AreEqual(AlbumTitle, albums.First().Title); } @@ -67,8 +71,23 @@ public async Task ListAllTest() [TestMethod] public async Task ListMissingTest() { - var albums = await _manager!.ListAsync(e => e.Title == "Missing"); + var albums = await _factory!.Albums.ListAsync(e => e.Title == "Missing"); Assert.AreEqual(0, albums!.Count); } + + [TestMethod] + public async Task DeleteTest() + { + var album = await _factory!.Albums.GetAsync(a => a.Title == AlbumTitle); + Assert.IsNotNull(album); + Assert.AreEqual(1, album.Tracks.Count); + + await _factory!.Albums.DeleteAsync(_albumId); + album = await _factory.Albums!.GetAsync(a => a.Title == AlbumTitle); + Assert.IsNull(album); + + var tracks = await _factory.Tracks.ListAsync(x => x.AlbumId == _albumId); + Assert.IsFalse(tracks.Any()); + } } } diff --git a/src/MusicCatalogue.Tests/TrackManagerTest.cs b/src/MusicCatalogue.Tests/TrackManagerTest.cs index bbf94ca..6d7fe00 100644 --- a/src/MusicCatalogue.Tests/TrackManagerTest.cs +++ b/src/MusicCatalogue.Tests/TrackManagerTest.cs @@ -76,5 +76,14 @@ public async Task ListMissingTest() var tracks = await _manager!.ListAsync(e => e.Title == "Missing"); Assert.AreEqual(0, tracks!.Count); } + + [TestMethod] + public async Task DeleteTest() + { + await _manager!.DeleteAsync(_albumId); + var tracks = await _manager!.ListAsync(e => e.AlbumId == _albumId); + Assert.AreEqual(0, tracks!.Count); + + } } } diff --git a/src/music-catalogue-ui/components/albumList.js b/src/music-catalogue-ui/components/albumList.js index 989d4c3..e102e7f 100644 --- a/src/music-catalogue-ui/components/albumList.js +++ b/src/music-catalogue-ui/components/albumList.js @@ -1,5 +1,7 @@ +import { useCallback } from "react"; import useAlbums from "@/hooks/useAlbums"; import AlbumRow from "./albumRow"; +import { apiDeleteAlbum, apiFetchAlbumsByArtist } from "@/helpers/api"; /** * Component to render the table of all albums by the specified artist @@ -9,6 +11,30 @@ import AlbumRow from "./albumRow"; const AlbumList = ({ artist, navigate, logout }) => { const { albums, setAlbums } = useAlbums(artist.id, logout); + /* Callback to prompt for confirmation and delete an album */ + const confirmDeleteAlbum = useCallback( + async (e, album) => { + // Prevent the default action associated with the click event + e.preventDefault(); + + // Show a confirmation message and get the user response + const message = `This will delete the album "${album.title}" - click OK to confirm`; + const result = confirm(message); + + // If they've confirmed the deletion ... + if (result) { + // ... delete the album and confirm the API call was successful + const result = await apiDeleteAlbum(album.id, logout); + if (result) { + // Successful, so refresh the album list + const fetchedAlbums = await apiFetchAlbumsByArtist(artist.id, logout); + setAlbums(fetchedAlbums); + } + } + }, + [artist, setAlbums, logout] + ); + return ( <>
@@ -21,15 +47,18 @@ const AlbumList = ({ artist, navigate, logout }) => { Album Title Genre Released + - {albums.map((a) => ( + {(albums ?? []).map((a) => ( ))} diff --git a/src/music-catalogue-ui/components/albumRow.js b/src/music-catalogue-ui/components/albumRow.js index 4602c02..661aff8 100644 --- a/src/music-catalogue-ui/components/albumRow.js +++ b/src/music-catalogue-ui/components/albumRow.js @@ -1,17 +1,33 @@ import pages from "@/helpers/navigation"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; /** * Component to render a row containing the details of a single album * @param {*} param0 * @returns */ -const AlbumRow = ({ artist, album, navigate }) => { +const AlbumRow = ({ artist, album, navigate, deleteAlbum }) => { return ( - navigate(pages.tracks, artist, album)}> - {artist.name} - {album.title} - {album.genre} - {album.released} + + navigate(pages.tracks, artist, album)}> + {artist.name} + + navigate(pages.tracks, artist, album)}> + {album.title} + + navigate(pages.tracks, artist, album)}> + {album.genre} + + navigate(pages.tracks, artist, album)}> + {album.released} + + + deleteAlbum(e, album)} + /> + ); }; diff --git a/src/music-catalogue-ui/helpers/api.js b/src/music-catalogue-ui/helpers/api.js index 63816c1..ee999d1 100644 --- a/src/music-catalogue-ui/helpers/api.js +++ b/src/music-catalogue-ui/helpers/api.js @@ -231,7 +231,7 @@ const apiFetchAlbumsByArtist = async (artistId, logout) => { * @returns */ const apiFetchAlbumById = async (albumId, logout) => { - // Call the API to get the details for the specifiedf album + // Call the API to get the details for the specified album const url = `${config.api.baseUrl}/albums/${albumId}`; const response = await fetch(url, { method: "GET", @@ -250,6 +250,29 @@ const apiFetchAlbumById = async (albumId, logout) => { } }; +/** + * Delete the album with the specified ID, along with all its tracks + * @param {*} albumId + * @param {*} logout + * @returns + */ +const apiDeleteAlbum = async (albumId, logout) => { + // Call the API to delete the specified album + const url = `${config.api.baseUrl}/albums/${albumId}`; + const response = await fetch(url, { + method: "DELETE", + headers: apiGetHeaders(), + }); + + if (response.status == 401) { + // Unauthorized so the token's likely expired - force a login + logout(); + } else { + // Return the response status code + return response.ok; + } +}; + /** * Look up an album using the REST API, calling the external service for the * details if not found in the local database @@ -362,6 +385,7 @@ export { apiFetchArtistById, apiFetchAlbumsByArtist, apiFetchAlbumById, + apiDeleteAlbum, apiLookupAlbum, apiRequestExport, apiJobStatusReport, diff --git a/wireframes/Music Catalogue - v8.00.pdf b/wireframes/Music Catalogue - v8.00.pdf new file mode 100644 index 0000000..2d9bd09 Binary files /dev/null and b/wireframes/Music Catalogue - v8.00.pdf differ diff --git a/wireframes/Music Catalogue.bmpr b/wireframes/Music Catalogue.bmpr index 6c3aeca..6ccb7a0 100644 Binary files a/wireframes/Music Catalogue.bmpr and b/wireframes/Music Catalogue.bmpr differ