diff --git a/README.md b/README.md index 09a1439..06d6c4c 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,11 @@ MusicCatalogue.LookupTool --lookup "John Coltrane" "Blue Train" catalogue Purchase Details - Note that retailers must be added using the retailer list and retailer editing page (see below) before they will appear in the drop-down on the purchase details page +- Clicking on the "Edit" icon opens the album editor to edit the album properties: + +Track List + +- Clicking on the "Add" button at the bottom of the album list will open a blank album editor to add and save a new album for the current artist - Clicking anywhere else on a row opens the track list for the album shown in that row: Track List diff --git a/diagrams/album-editor.png b/diagrams/album-editor.png new file mode 100644 index 0000000..dd14a8d Binary files /dev/null and b/diagrams/album-editor.png differ diff --git a/diagrams/album-list.png b/diagrams/album-list.png index c5b43da..c2ddbeb 100644 Binary files a/diagrams/album-list.png and b/diagrams/album-list.png differ diff --git a/src/MusicCatalogue.Api/Controllers/AlbumsController.cs b/src/MusicCatalogue.Api/Controllers/AlbumsController.cs index 13f24a8..ce154ac 100644 --- a/src/MusicCatalogue.Api/Controllers/AlbumsController.cs +++ b/src/MusicCatalogue.Api/Controllers/AlbumsController.cs @@ -66,6 +66,34 @@ public async Task>> GetAlbumsAsync([FromBody] Al return albums; } + /// + /// Update an album from a template contained in the request body + /// + /// + /// + [HttpPost] + [Route("")] + public async Task> AddAlbumAsync([FromBody] Album template) + { + // Make sure the "other" Genre exists as a fallback for album updates where no genre is given + var otherGenre = _factory.Genres.AddAsync(OtherGenre); + + // Add the album + var album = await _factory.Albums.AddAsync( + template.ArtistId, + template.GenreId ?? otherGenre.Id, + template.Title, + template.Released, + template.CoverUrl, + template.IsWishListItem, + template.Purchased, + template.Price, + template.RetailerId); + + // Return the new album + return album; + } + /// /// Update an album from a template contained in the request body /// diff --git a/src/MusicCatalogue.Logic/Database/AlbumManager.cs b/src/MusicCatalogue.Logic/Database/AlbumManager.cs index 3419e80..1d19e54 100644 --- a/src/MusicCatalogue.Logic/Database/AlbumManager.cs +++ b/src/MusicCatalogue.Logic/Database/AlbumManager.cs @@ -68,6 +68,7 @@ public async Task AddAsync( if (album == null) { + // Add the album album = new Album { ArtistId = artistId, @@ -82,6 +83,9 @@ public async Task AddAsync( }; await Context.Albums.AddAsync(album); await Context.SaveChangesAsync(); + + // Now re-retrieve it to populate related entities + album = await GetAsync(x => x.Id == album.Id); } return album; diff --git a/src/music-catalogue-ui/components/albumEditor.js b/src/music-catalogue-ui/components/albumEditor.js new file mode 100644 index 0000000..a6cdce6 --- /dev/null +++ b/src/music-catalogue-ui/components/albumEditor.js @@ -0,0 +1,190 @@ +import styles from "./albumEditor.module.css"; +import pages from "@/helpers/navigation"; +import catalogues from "@/helpers/catalogues"; +import FormInputField from "./formInputField"; +import CatalogueSelector from "./catalogueSelector"; +import { apiCreateAlbum, apiUpdateAlbum } from "@/helpers/apiAlbums"; +import { useState, useCallback } from "react"; +import GenreSelector from "./genreSelector"; + +/** + * Component to render an album editor, excluding purchase details that are + * maintained via their own component and the catalogue, which is maintained + * via the album list + * @param {*} artist + * @param {*} album + * @param {*} isWishList + * @param {*} navigate + * @param {*} logout + * @returns + */ +const AlbumEditor = ({ artist, album, isWishList, navigate, logout }) => { + // Get the initial genre selection + let initialGenre = null; + if (album != null) { + initialGenre = album.genre; + } + + // Get initial values for the remaining album properties + const initialTitle = album != null ? album.title : null; + const initialReleased = album != null ? album.released : null; + const initialCoverUrl = album != null ? album.coverUrl : null; + + // Setup state + const [title, setTitle] = useState(initialTitle); + const [genre, setGenre] = useState(initialGenre); + const [released, setReleased] = useState(initialReleased); + const [coverUrl, setCoverUrl] = useState(initialCoverUrl); + const [error, setError] = useState(""); + + const saveAlbum = useCallback( + async (e) => { + // Prevent the default action associated with the click event + e.preventDefault(); + + // Clear pre-existing errors + setError(""); + + try { + // Get the genre ID + const genreId = genre != null ? genre.id : null; + + // Either add or update the album, depending on whether there's an + // existing album or not + let updatedAlbum = null; + if (album == null) { + // Create the album + updatedAlbum = await apiCreateAlbum( + artist.id, + genreId, + title, + released, + coverUrl, + isWishList, + null, + null, + null, + logout + ); + } else { + // Update the existing album + updatedAlbum = await apiUpdateAlbum( + album.id, + artist.id, + genreId, + title, + released, + coverUrl, + album.isWishListItem, + album.purchased, + album.price, + album.retailerId, + logout + ); + } + + // Go back to the album list for the artist, which should reflect the + // updated details + navigate({ + page: pages.albums, + artist: artist, + isWishList: isWishList, + }); + } catch (ex) { + setError(`Error saving the updated album details: ${ex.message}`); + } + }, + [ + album, + artist, + title, + genre, + released, + coverUrl, + isWishList, + navigate, + logout, + ] + ); + + // Set the page title + const pageTitle = + album != null + ? `${album.title} - ${artist.name}` + : `New Album - ${artist.name}`; + + return ( + <> +
+
{pageTitle}
+
+
+
+
+ {error != "" ? ( +
{error}
+ ) : ( + <> + )} +
+
+ +
+
+ +
+ +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+ + ); +}; + +export default AlbumEditor; diff --git a/src/music-catalogue-ui/components/albumEditor.module.css b/src/music-catalogue-ui/components/albumEditor.module.css new file mode 100644 index 0000000..f159507 --- /dev/null +++ b/src/music-catalogue-ui/components/albumEditor.module.css @@ -0,0 +1,27 @@ +.albumEditorFormContainer { + display: flex; + justify-content: center; + align-items: center; +} + +.albumEditorForm { + width: 80%; + padding-top: 20px; + padding-bottom: 20px; +} + +.albumEditorFormLabel { + font-size: 14px; + font-weight: 600; + color: rgb(34, 34, 34); +} + +.albumEditorButton { + margin-left: 10px; + float: right; +} + +.albumEditorError { + font-weight: bold; + color: red; +} diff --git a/src/music-catalogue-ui/components/albumList.js b/src/music-catalogue-ui/components/albumList.js index ce78748..027b138 100644 --- a/src/music-catalogue-ui/components/albumList.js +++ b/src/music-catalogue-ui/components/albumList.js @@ -1,3 +1,5 @@ +import styles from "./albumList.module.css"; +import pages from "@/helpers/navigation"; import useAlbums from "@/hooks/useAlbums"; import AlbumRow from "./albumRow"; @@ -39,6 +41,7 @@ const AlbumList = ({ artist, isWishList, navigate, logout }) => { Retailer + @@ -56,6 +59,20 @@ const AlbumList = ({ artist, isWishList, navigate, logout }) => { ))} +
+ +
); }; diff --git a/src/music-catalogue-ui/components/albumList.module.css b/src/music-catalogue-ui/components/albumList.module.css new file mode 100644 index 0000000..b31efe3 --- /dev/null +++ b/src/music-catalogue-ui/components/albumList.module.css @@ -0,0 +1,3 @@ +.albumListAddButton { + float: right; +} diff --git a/src/music-catalogue-ui/components/albumPurchaseDetails.js b/src/music-catalogue-ui/components/albumPurchaseDetails.js index 4e361c4..9dbc702 100644 --- a/src/music-catalogue-ui/components/albumPurchaseDetails.js +++ b/src/music-catalogue-ui/components/albumPurchaseDetails.js @@ -3,10 +3,8 @@ import DatePicker from "react-datepicker"; import { useState, useCallback } from "react"; import CurrencyInput from "react-currency-input-field"; import config from "../config.json"; -import pages from "../helpers/navigation"; -import { apiCreateRetailer } from "@/helpers/apiRetailers"; +import pages from "@/helpers/navigation"; import { apiSetAlbumPurchaseDetails } from "@/helpers/apiAlbums"; -import FormInputField from "./formInputField"; import Select from "react-select"; import useRetailers from "@/hooks/useRetailers"; @@ -21,16 +19,29 @@ import useRetailers from "@/hooks/useRetailers"; const AlbumPurchaseDetails = ({ artist, album, navigate, logout }) => { const { retailers: retailers, setRetailers } = useRetailers(logout); - // Get the retailer name and purchase date from the album - const initialRetailerId = - album["retailer"] != null ? album["retailer"]["id"] : null; - const initialPurchaseDate = - album.purchased != null ? new Date(album.purchased) : new Date(); + // Construct the options for the retailer drop-down + let options = []; + for (let i = 0; i < retailers.length; i++) { + options = [ + ...options, + { value: retailers[i].id, label: retailers[i].name }, + ]; + } + + // Get the initial retailer selection and purchase date + let initialRetailer = null; + let initialPurchaseDate = new Date(); + if (album != null) { + initialRetailer = options.find((x) => x.value == album.retailerId); + if (album.purchased != null) { + initialPurchaseDate = new Date(album.purchased); + } + } // Set up state const [purchaseDate, setPurchaseDate] = useState(initialPurchaseDate); const [price, setPrice] = useState(album.price); - const [retailerId, setRetailerId] = useState(initialRetailerId); + const [retailer, setRetailer] = useState(initialRetailer); const [errorMessage, setErrorMessage] = useState(""); /* Callback to set album purchase details */ @@ -41,15 +52,16 @@ const AlbumPurchaseDetails = ({ artist, album, navigate, logout }) => { // Construct the values to be passed to the API const updatedPurchaseDate = - album.isWishListItem == true ? null : purchaseDate; - const updatedPrice = price == undefined ? null : price; + album.isWishListItem == false ? purchaseDate : null; + const updatedPrice = price != undefined ? price : null; + const updatedRetailerId = retailer != null ? retailer.value.id : null; // Apply the updates const updatedAlbum = await apiSetAlbumPurchaseDetails( album, updatedPurchaseDate, updatedPrice, - retailerId.value, + updatedRetailerId, logout ); @@ -66,18 +78,9 @@ const AlbumPurchaseDetails = ({ artist, album, navigate, logout }) => { setErrorMessage("Error updating the album purchase details"); } }, - [artist, album, price, purchaseDate, retailerId, logout, navigate] + [artist, album, price, purchaseDate, retailer, logout, navigate] ); - // Construct the options for the retailer drop-down - let options = []; - for (let i = 0; i < retailers.length; i++) { - options = [ - ...options, - { value: retailers[i].id, label: retailers[i].name }, - ]; - } - return ( <>
@@ -126,7 +129,11 @@ const AlbumPurchaseDetails = ({ artist, album, navigate, logout }) => { Retailer
-
diff --git a/src/music-catalogue-ui/components/albumRow.js b/src/music-catalogue-ui/components/albumRow.js index 9ebfd52..6f85e3b 100644 --- a/src/music-catalogue-ui/components/albumRow.js +++ b/src/music-catalogue-ui/components/albumRow.js @@ -4,7 +4,7 @@ import AlbumWishListActionIcon from "./albumWishListActionIcon"; import CurrencyFormatter from "./currencyFormatter"; import DateFormatter from "./dateFormatter"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faCoins } from "@fortawesome/free-solid-svg-icons"; +import { faCoins, faPenToSquare } from "@fortawesome/free-solid-svg-icons"; import { useCallback } from "react"; /** @@ -65,6 +65,18 @@ const AlbumRow = ({ setAlbums={setAlbums} /> + + + navigate({ + page: pages.albumEditor, + artist: artist, + album: album, + }) + } + /> + { - const [catalogue, setCatalogue] = useState(0); + // Set up state + const [catalogue, setCatalogue] = useState(catalogues.main); const [records, setRecords] = useState(null); const [message, setMessage] = useState(""); const [error, setError] = useState(""); @@ -25,7 +27,7 @@ const ArtistStatisticsReport = ({ logout }) => { e.preventDefault(); // Set the wishlist flag from the drop-down selection - const forWishList = catalogue.value == "wishlist"; + const forWishList = catalogue == catalogues.wishlist; // Fetch the report const fetchedRecords = await apiArtistStatisticsReport( @@ -71,12 +73,6 @@ const ArtistStatisticsReport = ({ logout }) => { [catalogue, logout] ); - // Construct a list of select list options for the directory - const options = [ - { value: "catalogue", label: "Main Catalogue" }, - { value: "wishlist", label: "Wish List" }, - ]; - return ( <>
@@ -101,11 +97,9 @@ const ArtistStatisticsReport = ({ logout }) => {
- + ); +}; + +export default CatalogueSelector; diff --git a/src/music-catalogue-ui/components/catalogueSelector.module.css b/src/music-catalogue-ui/components/catalogueSelector.module.css new file mode 100644 index 0000000..f579cf6 --- /dev/null +++ b/src/music-catalogue-ui/components/catalogueSelector.module.css @@ -0,0 +1,3 @@ +.catalogueSelector { + width: 200px; +} diff --git a/src/music-catalogue-ui/components/componentPicker.js b/src/music-catalogue-ui/components/componentPicker.js index e777872..f87e002 100644 --- a/src/music-catalogue-ui/components/componentPicker.js +++ b/src/music-catalogue-ui/components/componentPicker.js @@ -1,4 +1,4 @@ -import pages from "../helpers/navigation"; +import pages from "@/helpers/navigation"; import ArtistList from "./artistList"; import AlbumList from "./albumList"; import TrackList from "./trackList"; @@ -14,6 +14,7 @@ import RetailerList from "./retailerList"; import RetailerDetails from "./retailerDetails"; import RetailerEditor from "./retailerEditor"; import TrackEditor from "./trackEditor"; +import AlbumEditor from "./albumEditor"; /** * Component using the current context to select and render the current page @@ -43,6 +44,16 @@ const ComponentPicker = ({ context, navigate, logout }) => { logout={logout} /> ); + case pages.albumEditor: + return ( + + ); case pages.albumPurchaseDetails: return ( { return (
diff --git a/src/music-catalogue-ui/components/genreList.js b/src/music-catalogue-ui/components/genreList.js index 5d385e0..6561b3f 100644 --- a/src/music-catalogue-ui/components/genreList.js +++ b/src/music-catalogue-ui/components/genreList.js @@ -1,4 +1,4 @@ -import pages from "../helpers/navigation"; +import pages from "@/helpers/navigation"; import useGenres from "@/hooks/useGenres"; import GenreRow from "./genreRow"; diff --git a/src/music-catalogue-ui/components/genreSelector.js b/src/music-catalogue-ui/components/genreSelector.js new file mode 100644 index 0000000..3f9d4eb --- /dev/null +++ b/src/music-catalogue-ui/components/genreSelector.js @@ -0,0 +1,53 @@ +import Select from "react-select"; +import useGenres from "@/hooks/useGenres"; +import { useState } from "react"; + +/** + * Component to display the Genre selector + * @param {*} initialGenre + * @param {*} genreChangedCallback + * @param {*} logout + * @returns + */ +const GenreSelector = ({ initialGenre, genreChangedCallback, logout }) => { + const { genres, setGenres } = useGenres(logout); + + let options = []; + if (genres.length > 0) { + // Construct the options for the genres drop-down + for (let i = 0; i < genres.length; i++) { + options = [...options, { value: genres[i].id, label: genres[i].name }]; + } + } + + // Determine the initial selection + let selectedOption = null; + let initialInput = null; + if (initialGenre != null) { + selectedOption = options.find((x) => x.value === initialGenre.id); + initialInput = initialGenre.name; + } + + // Set up state + const [genre, setGenre] = useState(selectedOption); + + // Callback to update the genre state and notify the parent component + // that the genre has changed + const genreChanged = (e) => { + const updatedSelection = options.find((x) => x.value === e.value); + // Update local state with the selection from the drop-down + setGenre(updatedSelection); + + // Notify the parent component with a genre object + genreChangedCallback({ + id: updatedSelection.value, + name: updatedSelection.label, + }); + }; + + return ( +
diff --git a/src/music-catalogue-ui/components/locationMap.js b/src/music-catalogue-ui/components/locationMap.js index a872d3e..8458aa2 100644 --- a/src/music-catalogue-ui/components/locationMap.js +++ b/src/music-catalogue-ui/components/locationMap.js @@ -5,6 +5,12 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faMapMarkerAlt } from "@fortawesome/free-solid-svg-icons"; import { getStorageValue } from "@/helpers/storage"; +/** + * Component to render a location map, with pin at the specified coordinates + * @param {*} latitude + * @param {*} longitude + * @returns + */ const LocationMap = ({ latitude, longitude }) => { const location = { lat: latitude, lng: longitude }; const mapsApiKey = getStorageValue(secrets.mapsApiKey); diff --git a/src/music-catalogue-ui/components/lookupAlbum.js b/src/music-catalogue-ui/components/lookupAlbum.js index c2287ed..63ca292 100644 --- a/src/music-catalogue-ui/components/lookupAlbum.js +++ b/src/music-catalogue-ui/components/lookupAlbum.js @@ -12,11 +12,20 @@ import Select from "react-select"; * @returns */ const LookupAlbum = ({ navigate, logout }) => { + // Construct a list of select list options for the target directory + const options = [ + { value: "wishlist", label: "Wish List" }, + { value: "catalogue", label: "Main Catalogue" }, + ]; + + // Get the initial catalogue + const initialCatalogue = options.find((x) => x.value == "wishlist"); + // Configure state for the controlled fields const [artistName, setArtistName] = useState(""); const [albumTitle, setAlbumTitle] = useState(""); const [errorMessage, setErrorMessage] = useState(""); - const [catalogue, setCatalogue] = useState("wishlist"); + const [catalogue, setCatalogue] = useState(initialCatalogue); // Lookup navigation callback const lookup = useCallback( @@ -58,12 +67,6 @@ const LookupAlbum = ({ navigate, logout }) => { [artistName, albumTitle, catalogue, navigate, logout] ); - // Construct a list of select list options for the target directory - const options = [ - { value: "wishlist", label: "Wish List" }, - { value: "catalogue", label: "Main Catalogue" }, - ]; - return ( <>
@@ -96,7 +99,7 @@ const LookupAlbum = ({ navigate, logout }) => {