From ff800978d167ad426f6662691f481b996509c299 Mon Sep 17 00:00:00 2001 From: Sander Vocke Date: Fri, 2 Oct 2020 14:50:57 +0200 Subject: [PATCH] Add tag deletion to back-end, applying of tag changes to front-end --- client/src/api.ts | 8 +++ .../components/windows/album/AlbumWindow.tsx | 2 +- .../windows/artist/ArtistWindow.tsx | 2 +- .../windows/manage_tags/ManageTagsWindow.tsx | 20 +++++- .../windows/manage_tags/TagChange.tsx | 57 ++++++++++++++++- .../components/windows/query/QueryWindow.tsx | 2 +- .../components/windows/song/SongWindow.tsx | 2 +- .../src/components/windows/tag/TagWindow.tsx | 2 +- .../Backend.tsx => backend/queries.tsx} | 2 +- client/src/lib/backend/tags.tsx | 45 +++++++++++++ server/app.ts | 2 + server/endpoints/DeleteTagEndpointHandler.ts | 64 +++++++++++++++++++ 12 files changed, 197 insertions(+), 11 deletions(-) rename client/src/lib/{query/Backend.tsx => backend/queries.tsx} (98%) create mode 100644 client/src/lib/backend/tags.tsx create mode 100644 server/endpoints/DeleteTagEndpointHandler.ts diff --git a/client/src/api.ts b/client/src/api.ts index b671e3e..c15c3d6 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -302,4 +302,12 @@ export interface TagDetailsResponse { } export function checkTagDetailsRequest(req: any): boolean { return true; +} + +// Delete tag (DELETE). +export const DeleteTagEndpoint = '/tag/:id'; +export interface DeleteTagRequest { } +export interface DeleteTagResponse { } +export function checkDeleteTagRequest(req: any): boolean { + return true; } \ No newline at end of file diff --git a/client/src/components/windows/album/AlbumWindow.tsx b/client/src/components/windows/album/AlbumWindow.tsx index 0f4070e..d879bfc 100644 --- a/client/src/components/windows/album/AlbumWindow.tsx +++ b/client/src/components/windows/album/AlbumWindow.tsx @@ -9,7 +9,7 @@ import SubmitChangesButton from '../../common/SubmitChangesButton'; import SongTable, { SongGetters } from '../../tables/ResultsTable'; import { saveAlbumChanges } from '../../../lib/saveChanges'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; -import { queryAlbums, querySongs } from '../../../lib/query/Backend'; +import { queryAlbums, querySongs } from '../../../lib/backend/queries'; var _ = require('lodash'); export type AlbumMetadata = serverApi.AlbumDetails; diff --git a/client/src/components/windows/artist/ArtistWindow.tsx b/client/src/components/windows/artist/ArtistWindow.tsx index 9e0c3af..fd76abb 100644 --- a/client/src/components/windows/artist/ArtistWindow.tsx +++ b/client/src/components/windows/artist/ArtistWindow.tsx @@ -9,7 +9,7 @@ import SubmitChangesButton from '../../common/SubmitChangesButton'; import SongTable, { SongGetters } from '../../tables/ResultsTable'; import { saveArtistChanges } from '../../../lib/saveChanges'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; -import { queryArtists, querySongs } from '../../../lib/query/Backend'; +import { queryArtists, querySongs } from '../../../lib/backend/queries'; var _ = require('lodash'); export type ArtistMetadata = serverApi.ArtistDetails; diff --git a/client/src/components/windows/manage_tags/ManageTagsWindow.tsx b/client/src/components/windows/manage_tags/ManageTagsWindow.tsx index d2ed83b..b828ef8 100644 --- a/client/src/components/windows/manage_tags/ManageTagsWindow.tsx +++ b/client/src/components/windows/manage_tags/ManageTagsWindow.tsx @@ -5,8 +5,8 @@ import LoyaltyIcon from '@material-ui/icons/Loyalty'; import ArrowRightIcon from '@material-ui/icons/ArrowRight'; import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown'; import ManageTagMenu from './ManageTagMenu'; -import ControlTagChanges, { TagChange, TagChangeType } from './TagChange'; -import { queryTags } from '../../../lib/query/Backend'; +import ControlTagChanges, { TagChange, TagChangeType, submitTagChanges } from './TagChange'; +import { queryTags } from '../../../lib/backend/queries'; import NewTagMenu from './NewTagMenu'; import { v4 as genUuid } from 'uuid'; var _ = require('lodash'); @@ -22,6 +22,7 @@ export interface ManageTagsWindowState extends WindowState { export enum ManageTagsWindowActions { SetFetchedTags = "SetFetchedTags", SetPendingChanges = "SetPendingChanges", + Reset = "Reset", } export function ManageTagsWindowReducer(state: ManageTagsWindowState, action: any) { @@ -36,6 +37,12 @@ export function ManageTagsWindowReducer(state: ManageTagsWindowState, action: an ...state, pendingChanges: action.value, } + case ManageTagsWindowActions.Reset: + return { + ...state, + pendingChanges: [], + fetchedTags: null, + } default: throw new Error("Unimplemented ManageTagsWindow state update.") } @@ -344,7 +351,14 @@ export default function ManageTagsWindow(props: { type: ManageTagsWindowActions.SetPendingChanges, value: [], })} - onSave={() => { }} + onSave={() => { + submitTagChanges(props.state.pendingChanges).then(() => { + console.log("reset!") + props.dispatch({ + type: ManageTagsWindowActions.Reset + }); + }) + }} getTagDetails={(id: string) => tagsWithChanges[id]} /> } diff --git a/client/src/components/windows/manage_tags/TagChange.tsx b/client/src/components/windows/manage_tags/TagChange.tsx index f5c2838..b15b160 100644 --- a/client/src/components/windows/manage_tags/TagChange.tsx +++ b/client/src/components/windows/manage_tags/TagChange.tsx @@ -1,9 +1,10 @@ import React, { useState, useEffect } from 'react'; import { Typography, Chip, CircularProgress, Box, Paper } from '@material-ui/core'; -import { queryTags } from '../../../lib/query/Backend'; +import { queryTags } from '../../../lib/backend/queries'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import DiscardChangesButton from '../../common/DiscardChangesButton'; import SubmitChangesButton from '../../common/SubmitChangesButton'; +import { createTag, modifyTag, deleteTag } from '../../../lib/backend/tags'; export enum TagChangeType { Delete = "Delete", @@ -21,6 +22,58 @@ export interface TagChange { name?: string, } +export async function submitTagChanges(changes: TagChange[]) { + // Upon entering this function, some tags have a real numeric MuDBase ID (stringified), + // while others have a UUID string which is a placeholder until the tag is created. + // While applying the changes, UUIDs will be replaced by real numeric IDs. + // Therefore we maintain a lookup table for mapping the old to the new. + var id_lookup: Record = {} + + const getId = (id_string: string) => { + return (Number(id_string) === NaN) ? + id_lookup[id_string] : Number(id_string); + } + + for (const change of changes) { + // If string is of form "1", convert to ID number directly. + // Otherwise, look it up in the table. + const parentId = change.parent ? getId(change.parent) : undefined; + const numericId = change.id ? getId(change.id) : undefined; + switch (change.type) { + case TagChangeType.Create: + if (!change.name) { throw new Error("Cannot create tag without name"); } + const { id } = await createTag({ + name: change.name, + parentId: parentId, + }); + id_lookup[change.id] = id; + break; + case TagChangeType.MoveTo: + if (!numericId) { throw new Error("Cannot modify tag with no numeric ID"); } + await modifyTag( + numericId, + { + parentId: parentId, + }) + break; + case TagChangeType.Rename: + if (!numericId) { throw new Error("Cannot modify tag with no numeric ID"); } + await modifyTag( + numericId, + { + name: change.name, + }) + break; + case TagChangeType.Delete: + if (!numericId) { throw new Error("Cannot delete tag with no numeric ID"); } + await deleteTag(numericId) + break; + default: + throw new Error("Unimplemented tag change"); + } + } +} + export function TagChangeDisplay(props: { change: TagChange, getTagDetails: (id: string) => any, @@ -52,7 +105,7 @@ export function TagChangeDisplay(props: { return Move {MainTag} from {OldParent} to {NewParent} case TagChangeType.Create: return props.change.parent ? - Create {MainTag} under : + Create {MainTag} under : Create {MainTag} default: throw new Error("Unhandled tag change type") diff --git a/client/src/components/windows/query/QueryWindow.tsx b/client/src/components/windows/query/QueryWindow.tsx index 2ec8f76..b410a9d 100644 --- a/client/src/components/windows/query/QueryWindow.tsx +++ b/client/src/components/windows/query/QueryWindow.tsx @@ -5,7 +5,7 @@ import QueryBuilder from '../../querybuilder/QueryBuilder'; import * as serverApi from '../../../api'; import SongTable from '../../tables/ResultsTable'; import { songGetters } from '../../../lib/songGetters'; -import { queryArtists, querySongs, queryAlbums, queryTags } from '../../../lib/query/Backend'; +import { queryArtists, querySongs, queryAlbums, queryTags } from '../../../lib/backend/queries'; import { grey } from '@material-ui/core/colors'; import { WindowState } from '../Windows'; var _ = require('lodash'); diff --git a/client/src/components/windows/song/SongWindow.tsx b/client/src/components/windows/song/SongWindow.tsx index ab304c1..e7048aa 100644 --- a/client/src/components/windows/song/SongWindow.tsx +++ b/client/src/components/windows/song/SongWindow.tsx @@ -12,7 +12,7 @@ import EditableText from '../../common/EditableText'; import SubmitChangesButton from '../../common/SubmitChangesButton'; import { saveSongChanges } from '../../../lib/saveChanges'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; -import { querySongs } from '../../../lib/query/Backend'; +import { querySongs } from '../../../lib/backend/queries'; export type SongMetadata = serverApi.SongDetails; export type SongMetadataChanges = serverApi.ModifySongRequest; diff --git a/client/src/components/windows/tag/TagWindow.tsx b/client/src/components/windows/tag/TagWindow.tsx index 19499b8..ec33f91 100644 --- a/client/src/components/windows/tag/TagWindow.tsx +++ b/client/src/components/windows/tag/TagWindow.tsx @@ -8,7 +8,7 @@ import EditableText from '../../common/EditableText'; import SubmitChangesButton from '../../common/SubmitChangesButton'; import SongTable, { SongGetters } from '../../tables/ResultsTable'; import { saveTagChanges } from '../../../lib/saveChanges'; -import { queryTags, querySongs } from '../../../lib/query/Backend'; +import { queryTags, querySongs } from '../../../lib/backend/queries'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; var _ = require('lodash'); diff --git a/client/src/lib/query/Backend.tsx b/client/src/lib/backend/queries.tsx similarity index 98% rename from client/src/lib/query/Backend.tsx rename to client/src/lib/backend/queries.tsx index 21ad647..d93ef40 100644 --- a/client/src/lib/query/Backend.tsx +++ b/client/src/lib/backend/queries.tsx @@ -1,5 +1,5 @@ import * as serverApi from '../../api'; -import { QueryElem, toApiQuery } from './Query'; +import { QueryElem, toApiQuery } from '../query/Query'; export interface QueryArgs { query?: QueryElem, diff --git a/client/src/lib/backend/tags.tsx b/client/src/lib/backend/tags.tsx new file mode 100644 index 0000000..2000a3e --- /dev/null +++ b/client/src/lib/backend/tags.tsx @@ -0,0 +1,45 @@ +import * as serverApi from '../../api'; + +export async function createTag(details: serverApi.CreateTagRequest) { + const requestOpts = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(details), + }; + + const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.CreateTagEndpoint, requestOpts) + if (!response.ok) { + throw new Error("Response to tag creation not OK: " + JSON.stringify(response)); + } + return await response.json(); +} + +export async function modifyTag(id: number, details: serverApi.ModifyTagRequest) { + const requestOpts = { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(details), + }; + + const response = await fetch( + (process.env.REACT_APP_BACKEND || "") + serverApi.ModifyTagEndpoint.replace(':id', id.toString()), + requestOpts + ); + if (!response.ok) { + throw new Error("Response to tag modification not OK: " + JSON.stringify(response)); + } +} + +export async function deleteTag(id: number) { + const requestOpts = { + method: 'DELETE', + }; + + const response = await fetch( + (process.env.REACT_APP_BACKEND || "") + serverApi.DeleteTagEndpoint.replace(':id', id.toString()), + requestOpts + ); + if (!response.ok) { + throw new Error("Response to tag deletion not OK: " + JSON.stringify(response)); + } +} \ No newline at end of file diff --git a/server/app.ts b/server/app.ts index 8d1922a..17927ce 100644 --- a/server/app.ts +++ b/server/app.ts @@ -15,6 +15,7 @@ import { TagDetailsEndpointHandler } from './endpoints/TagDetailsEndpointHandler import { CreateAlbumEndpointHandler } from './endpoints/CreateAlbumEndpointHandler'; import { ModifyAlbumEndpointHandler } from './endpoints/ModifyAlbumEndpointHandler'; import { AlbumDetailsEndpointHandler } from './endpoints/AlbumDetailsEndpointHandler'; +import { DeleteTagEndpointHandler } from './endpoints/DeleteTagEndpointHandler'; import * as endpointTypes from './endpoints/types'; const invokeHandler = (handler:endpointTypes.EndpointHandler, knex: Knex) => { @@ -53,6 +54,7 @@ const SetupApp = (app: any, knex: Knex, apiBaseUrl: string) => { app.post(apiBaseUrl + api.CreateAlbumEndpoint, invokeWithKnex(CreateAlbumEndpointHandler)); app.put(apiBaseUrl + api.ModifyAlbumEndpoint, invokeWithKnex(ModifyAlbumEndpointHandler)); app.get(apiBaseUrl + api.AlbumDetailsEndpoint, invokeWithKnex(AlbumDetailsEndpointHandler)); + app.delete(apiBaseUrl + api.DeleteTagEndpoint, invokeWithKnex(DeleteTagEndpointHandler)); } export { SetupApp } \ No newline at end of file diff --git a/server/endpoints/DeleteTagEndpointHandler.ts b/server/endpoints/DeleteTagEndpointHandler.ts new file mode 100644 index 0000000..97b40d1 --- /dev/null +++ b/server/endpoints/DeleteTagEndpointHandler.ts @@ -0,0 +1,64 @@ +import * as api from '../../client/src/api'; +import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; +import Knex from 'knex'; + +export const DeleteTagEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkDeleteTagRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid DeleteTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.DeleteTagRequest = req.body; + + console.log("Delete Tag:", reqObject); + + await knex.transaction(async (trx) => { + try { + // Start retrieving any child tags. + const childTagsPromise = trx.select('id') + .from('tags') + .where({ 'parentId': req.params.id }); + + // Start retrieving the tag itself. + const tagPromise = trx.select('id') + .from('tags') + .where({ id: req.params.id }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Wait for the requests to finish. + var [tag, children] = await Promise.all([tagPromise, childTagsPromise]); + + // If there are any children, refuse the deletion + if(children && children.length > 0) { + const e: EndpointError = { + internalMessage: 'Invalid DeleteTag request: tag ' + req.params.id.toString() + " has children", + httpStatus: 400 + }; + throw e; + } + + // Check that we found all objects we need. + if (!tag) { + const e: EndpointError = { + internalMessage: 'Tag or parent does not exist for DeleteTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + // Delete the tag. + await trx('tags') + .where({ 'id': req.params.id }) + .del(); + + // Respond to the request. + res.status(200).send(); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} \ No newline at end of file