parent
04e47349dd
commit
a689613a45
25 changed files with 1896 additions and 1014 deletions
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,13 @@ |
||||
import React from 'react'; |
||||
import { Box, Button } from '@material-ui/core'; |
||||
|
||||
export default function DiscardChangesButton(props: any) { |
||||
return <Box> |
||||
<Button |
||||
{...props} |
||||
variant="contained" color="primary" |
||||
> |
||||
Discard Changes |
||||
</Button> |
||||
</Box> |
||||
} |
@ -0,0 +1,23 @@ |
||||
import React, { useState } from 'react'; |
||||
import { TextField } from '@material-ui/core'; |
||||
|
||||
export default function MenuEditText(props: { |
||||
label: string, |
||||
onSubmit: (s: string) => void, |
||||
}) { |
||||
const [input, setInput] = useState(""); |
||||
|
||||
return <TextField |
||||
label={props.label} |
||||
variant="outlined" |
||||
value={input} |
||||
onChange={(e: any) => setInput(e.target.value)} |
||||
onKeyDown={(e: any) => { |
||||
if (e.key === 'Enter') { |
||||
// User submitted free-form value.
|
||||
props.onSubmit(input); |
||||
e.preventDefault(); |
||||
} |
||||
}} |
||||
/> |
||||
} |
@ -0,0 +1,97 @@ |
||||
import React, { useState } from 'react'; |
||||
import { Menu, MenuItem, TextField, Input } from '@material-ui/core'; |
||||
import NestedMenuItem from "material-ui-nested-menu-item"; |
||||
import MenuEditText from '../../common/MenuEditText'; |
||||
|
||||
export function PickTag(props: { |
||||
tags: any[] |
||||
open: boolean |
||||
root: boolean |
||||
onPick: (v: string | null) => void |
||||
}) { |
||||
|
||||
return <> |
||||
{props.root && <MenuItem onClick={() => props.onPick(null)}>/</MenuItem>} |
||||
{props.tags.map((tag: any) => { |
||||
if ('children' in tag && tag.children.length > 0) { |
||||
return <NestedMenuItem |
||||
parentMenuOpen={props.open} |
||||
label={tag.name} |
||||
onClick={() => props.onPick(tag.tagId.toString())} |
||||
> |
||||
<PickTag tags={tag.children} open={props.open} root={false} onPick={props.onPick} /> |
||||
</NestedMenuItem> |
||||
} |
||||
return <MenuItem onClick={() => props.onPick(tag.tagId.toString())}>{tag.name}</MenuItem> |
||||
}) |
||||
}</> |
||||
} |
||||
|
||||
export default function ManageTagMenu(props: { |
||||
position: null | number[], |
||||
open: boolean, |
||||
onClose: () => void, |
||||
onRename: (s: string) => void, |
||||
onDelete: () => void, |
||||
onMove: (to: string | null) => void, |
||||
onMergeInto: (to: string) => void, |
||||
onOpenInTab: () => void, |
||||
tag: any, |
||||
changedTags: any[], // Tags organized hierarchically with "children" fields
|
||||
}) { |
||||
const pos = props.open && props.position ? |
||||
{ left: props.position[0], top: props.position[1] } |
||||
: { left: 0, top: 0 } |
||||
|
||||
return <Menu |
||||
open={props.open} |
||||
anchorReference="anchorPosition" |
||||
anchorPosition={pos} |
||||
keepMounted |
||||
onClose={props.onClose} |
||||
> |
||||
<MenuItem |
||||
onClick={() => { |
||||
props.onClose(); |
||||
props.onOpenInTab(); |
||||
}} |
||||
>Browse</MenuItem> |
||||
<MenuItem |
||||
onClick={() => { |
||||
props.onClose(); |
||||
props.onDelete(); |
||||
}} |
||||
>Delete</MenuItem> |
||||
<NestedMenuItem |
||||
parentMenuOpen={props.open} |
||||
label="Rename" |
||||
> |
||||
<MenuEditText |
||||
label="New name" |
||||
onSubmit={(s: string) => { |
||||
props.onClose(); |
||||
props.onRename(s); |
||||
}} |
||||
/> |
||||
</NestedMenuItem> |
||||
<NestedMenuItem |
||||
parentMenuOpen={props.open} |
||||
label="Move to" |
||||
> |
||||
<PickTag tags={props.changedTags} open={props.open} root={true} onPick={(v: string | null) => { |
||||
props.onClose(); |
||||
props.onMove(v); |
||||
}} /> |
||||
</NestedMenuItem> |
||||
<NestedMenuItem |
||||
parentMenuOpen={props.open} |
||||
label="Merge into" |
||||
> |
||||
<PickTag tags={props.changedTags} open={props.open} root={false} onPick={(v: string | null) => { |
||||
if(v === null) { return; } |
||||
props.onClose(); |
||||
props.onMergeInto(v); |
||||
}} /> |
||||
</NestedMenuItem> |
||||
</Menu> |
||||
} |
@ -0,0 +1,479 @@ |
||||
import React, { useEffect, useState, ReactFragment } from 'react'; |
||||
import { WindowState, newWindowReducer, WindowType } from '../Windows'; |
||||
import { Box, Typography, Chip, IconButton, useTheme, Button } from '@material-ui/core'; |
||||
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, submitTagChanges } from './TagChange'; |
||||
import { queryTags } from '../../../lib/backend/queries'; |
||||
import NewTagMenu from './NewTagMenu'; |
||||
import { v4 as genUuid } from 'uuid'; |
||||
import { MainWindowStateActions } from '../../MainWindow'; |
||||
import LocalOfferIcon from '@material-ui/icons/LocalOffer'; |
||||
import { songGetters } from '../../../lib/songGetters'; |
||||
import Alert from '@material-ui/lab/Alert'; |
||||
var _ = require('lodash'); |
||||
|
||||
export interface ManageTagsWindowState extends WindowState { |
||||
// Tags are indexed by a string ID. This can be a stringified MuDBase ID integer,
|
||||
// or a UID for tags which only exist in the front-end and haven't been committed
|
||||
// to the database.
|
||||
fetchedTags: Record<string, any> | null, |
||||
pendingChanges: TagChange[], |
||||
alert: ReactFragment | null, // For notifications such as errors
|
||||
} |
||||
|
||||
export enum ManageTagsWindowActions { |
||||
SetFetchedTags = "SetFetchedTags", |
||||
SetPendingChanges = "SetPendingChanges", |
||||
Reset = "Reset", |
||||
SetAlert = "SetAlert", |
||||
} |
||||
|
||||
export function ManageTagsWindowReducer(state: ManageTagsWindowState, action: any) { |
||||
switch (action.type) { |
||||
case ManageTagsWindowActions.SetFetchedTags: |
||||
return { |
||||
...state, |
||||
fetchedTags: action.value, |
||||
} |
||||
case ManageTagsWindowActions.SetPendingChanges: |
||||
return { |
||||
...state, |
||||
pendingChanges: action.value, |
||||
} |
||||
case ManageTagsWindowActions.Reset: |
||||
return { |
||||
...state, |
||||
pendingChanges: [], |
||||
fetchedTags: null, |
||||
alert: null, |
||||
} |
||||
case ManageTagsWindowActions.SetAlert: |
||||
return { |
||||
...state, |
||||
alert: action.value, |
||||
} |
||||
default: |
||||
throw new Error("Unimplemented ManageTagsWindow state update.") |
||||
} |
||||
} |
||||
|
||||
export function organiseTags(allTags: Record<string, any>, fromId: string | null): any[] { |
||||
const base = Object.values(allTags).filter((tag: any) => { |
||||
var par: any = ("proposedParent" in tag) ? tag.proposedParent : tag.parentId; |
||||
|
||||
return (fromId === null && !par) || |
||||
(par && par === fromId) |
||||
}); |
||||
|
||||
return base.map((tag: any) => { |
||||
return { |
||||
...tag, |
||||
children: organiseTags(allTags, tag.tagId), |
||||
} |
||||
}); |
||||
} |
||||
|
||||
export async function getAllTags() { |
||||
return (async () => { |
||||
var retval: Record<string, any> = {}; |
||||
const tags = await queryTags({ |
||||
query: undefined, |
||||
offset: 0, |
||||
limit: -1, |
||||
}); |
||||
// Convert numeric IDs to string IDs because that is
|
||||
// what we work with within this component.
|
||||
tags.forEach((tag: any) => { |
||||
retval[tag.tagId.toString()] = { |
||||
...tag, |
||||
tagId: tag.tagId && tag.tagId.toString(), |
||||
parentId: tag.parentId && tag.parentId.toString(), |
||||
childIds: tag.childIds && tag.childIds.map((c: number) => c.toString()), |
||||
} |
||||
}); |
||||
return retval; |
||||
})(); |
||||
} |
||||
|
||||
export function ExpandArrow(props: { |
||||
expanded: boolean, |
||||
onSetExpanded: (v: boolean) => void, |
||||
}) { |
||||
return props.expanded ? |
||||
<IconButton size="small" onClick={() => props.onSetExpanded(false)}><ArrowDropDownIcon /></IconButton> : |
||||
<IconButton size="small" onClick={() => props.onSetExpanded(true)}><ArrowRightIcon /></IconButton>; |
||||
} |
||||
|
||||
export function CreateTagButton(props: any) { |
||||
return <Box display="flex"> |
||||
<Box visibility='hidden'> |
||||
<ExpandArrow expanded={false} onSetExpanded={(v: boolean) => { }} /> |
||||
</Box> |
||||
<Button style={{ textTransform: 'none' }} variant="outlined" {...props}>New Tag...</Button> |
||||
</Box> |
||||
} |
||||
|
||||
export function SingleTag(props: { |
||||
tag: any, |
||||
prependElems: any[], |
||||
dispatch: (action: any) => void, |
||||
mainDispatch: (action: any) => void, |
||||
state: ManageTagsWindowState, |
||||
changedTags: any[], |
||||
}) { |
||||
const tag = props.tag; |
||||
const hasChildren = 'children' in tag && tag.children.length > 0; |
||||
|
||||
const [menuPos, setMenuPos] = React.useState<null | number[]>(null); |
||||
const [expanded, setExpanded] = useState<boolean>(false); |
||||
const theme = useTheme(); |
||||
|
||||
const onOpenMenu = (e: any) => { |
||||
setMenuPos([e.clientX, e.clientY]) |
||||
}; |
||||
const onCloseMenu = () => { |
||||
setMenuPos(null); |
||||
}; |
||||
|
||||
var tagLabel: any = tag.name; |
||||
if ("proposedName" in tag) { |
||||
tagLabel = <><del style={{ color: theme.palette.text.secondary }}>{tag.name}</del>→{tag.proposedName}</>; |
||||
} else if ("proposeDelete" in tag && tag.proposeDelete) { |
||||
tagLabel = <><del style={{ color: theme.palette.text.secondary }}>{tag.name}</del></>; |
||||
} |
||||
|
||||
const TagChip = (props: any) => <Box |
||||
style={{ opacity: props.transparent ? 0.5 : 1.0 }} |
||||
> |
||||
<Chip |
||||
size="small" |
||||
label={props.label} |
||||
onClick={onOpenMenu} |
||||
/> |
||||
</Box>; |
||||
|
||||
return <> |
||||
<Box display="flex" alignItems="center"> |
||||
<Box visibility={hasChildren ? 'visible' : 'hidden'}> |
||||
<ExpandArrow expanded={expanded} onSetExpanded={setExpanded} /> |
||||
</Box> |
||||
{props.prependElems} |
||||
<TagChip transparent={tag.proposeDelete} label={tagLabel} /> |
||||
</Box> |
||||
{hasChildren && expanded && tag.children.map((child: any) => <SingleTag |
||||
tag={child} |
||||
prependElems={[...props.prependElems, |
||||
<TagChip transparent={true} label={tagLabel} />, |
||||
<Typography variant="h5">/</Typography>]} |
||||
dispatch={props.dispatch} |
||||
mainDispatch={props.mainDispatch} |
||||
state={props.state} |
||||
changedTags={props.changedTags} |
||||
/>)} |
||||
<ManageTagMenu |
||||
position={menuPos} |
||||
open={menuPos !== null} |
||||
onClose={onCloseMenu} |
||||
onOpenInTab={() => { |
||||
props.mainDispatch({ |
||||
type: MainWindowStateActions.AddTab, |
||||
tabState: { |
||||
tabLabel: <><LocalOfferIcon />{tag.name}</>, |
||||
tagId: tag.tagId, |
||||
metadata: null, |
||||
songGetters: songGetters, |
||||
songsWithTag: null, |
||||
}, |
||||
tabReducer: newWindowReducer[WindowType.Tag], |
||||
tabType: WindowType.Tag, |
||||
}) |
||||
}} |
||||
onRename={(s: string) => { |
||||
props.dispatch({ |
||||
type: ManageTagsWindowActions.SetPendingChanges, |
||||
value: [ |
||||
...props.state.pendingChanges, |
||||
{ |
||||
type: TagChangeType.Rename, |
||||
name: s, |
||||
id: tag.tagId, |
||||
} |
||||
] |
||||
}) |
||||
props.dispatch({ |
||||
type: ManageTagsWindowActions.SetAlert, |
||||
value: null, |
||||
}) |
||||
}} |
||||
onDelete={() => { |
||||
props.dispatch({ |
||||
type: ManageTagsWindowActions.SetPendingChanges, |
||||
value: [ |
||||
...props.state.pendingChanges, |
||||
{ |
||||
type: TagChangeType.Delete, |
||||
id: tag.tagId, |
||||
} |
||||
] |
||||
}) |
||||
props.dispatch({ |
||||
type: ManageTagsWindowActions.SetAlert, |
||||
value: null, |
||||
}) |
||||
}} |
||||
onMove={(to: string | null) => { |
||||
props.dispatch({ |
||||
type: ManageTagsWindowActions.SetPendingChanges, |
||||
value: [ |
||||
...props.state.pendingChanges, |
||||
{ |
||||
type: TagChangeType.MoveTo, |
||||
id: tag.tagId, |
||||
parent: to, |
||||
} |
||||
] |
||||
}) |
||||
props.dispatch({ |
||||
type: ManageTagsWindowActions.SetAlert, |
||||
value: null, |
||||
}) |
||||
}} |
||||
onMergeInto={(into: string) => { |
||||
props.dispatch({ |
||||
type: ManageTagsWindowActions.SetPendingChanges, |
||||
value: [ |
||||
...props.state.pendingChanges, |
||||
{ |
||||
type: TagChangeType.MergeTo, |
||||
id: tag.tagId, |
||||
into: into, |
||||
} |
||||
] |
||||
}) |
||||
props.dispatch({ |
||||
type: ManageTagsWindowActions.SetAlert, |
||||
value: null, |
||||
}) |
||||
}} |
||||
tag={tag} |
||||
changedTags={props.changedTags} |
||||
/> |
||||
</> |
||||
} |
||||
|
||||
function annotateTagsWithChanges(tags: Record<string, any>, changes: TagChange[]) { |
||||
var retval: Record<string, any> = _.cloneDeep(tags); |
||||
|
||||
const applyDelete = (id: string) => { |
||||
retval[id].proposeDelete = true; |
||||
Object.values(tags).filter((t: any) => t.parentId === id) |
||||
.forEach((child: any) => applyDelete(child.tagId)); |
||||
} |
||||
|
||||
changes.forEach((change: TagChange) => { |
||||
switch (change.type) { |
||||
case TagChangeType.Rename: |
||||
retval[change.id].proposedName = change.name; |
||||
break; |
||||
case TagChangeType.Delete: |
||||
applyDelete(change.id); |
||||
break; |
||||
case TagChangeType.MoveTo: |
||||
retval[change.id].proposedParent = change.parent; |
||||
break; |
||||
case TagChangeType.MergeTo: |
||||
retval[change.id].proposedMergeInto = change.into; |
||||
break; |
||||
case TagChangeType.Create: |
||||
retval[change.id] = { |
||||
isNewTag: true, |
||||
name: change.name, |
||||
parentId: change.parent, |
||||
tagId: change.id, |
||||
} |
||||
if (change.parent) { |
||||
retval[change.parent].childIds = |
||||
[...retval[change.parent].childIds, change.id] |
||||
} |
||||
break; |
||||
default: |
||||
throw new Error("Unimplemented tag change") |
||||
} |
||||
}) |
||||
return retval; |
||||
} |
||||
|
||||
function applyTagsChanges(tags: Record<string, any>, changes: TagChange[]) { |
||||
var retval = _.cloneDeep(tags); |
||||
|
||||
const applyDelete = (id: string) => { |
||||
Object.values(tags).filter((t: any) => t.parentId === id) |
||||
.forEach((child: any) => applyDelete(child.tagId)); |
||||
delete retval[id].proposeDelete; |
||||
} |
||||
|
||||
changes.forEach((change: TagChange) => { |
||||
switch (change.type) { |
||||
case TagChangeType.Rename: |
||||
retval[change.id].name = change.name; |
||||
break; |
||||
case TagChangeType.Delete: |
||||
applyDelete(change.id); |
||||
break; |
||||
case TagChangeType.MoveTo: |
||||
retval[change.id].parentId = change.parent; |
||||
if (change.parent === null) { delete retval[change.id].parentId; } |
||||
break; |
||||
case TagChangeType.MergeTo: |
||||
applyDelete(change.id); |
||||
break; |
||||
case TagChangeType.Create: |
||||
retval[change.id] = { |
||||
name: change.name, |
||||
tagId: change.id, |
||||
parentId: change.parent, |
||||
isNewTag: true, |
||||
} |
||||
if (change.parent) { |
||||
retval[change.parent].childIds = |
||||
[...retval[change.parent].childIds, change.id] |
||||
} |
||||
break; |
||||
default: |
||||
throw new Error("Unimplemented tag change") |
||||
} |
||||
}) |
||||
return retval; |
||||
} |
||||
|
||||
export default function ManageTagsWindow(props: { |
||||
state: ManageTagsWindowState, |
||||
dispatch: (action: any) => void, |
||||
mainDispatch: (action: any) => void, |
||||
}) { |
||||
const [newTagMenuPos, setNewTagMenuPos] = React.useState<null | number[]>(null); |
||||
|
||||
const onOpenNewTagMenu = (e: any) => { |
||||
setNewTagMenuPos([e.clientX, e.clientY]) |
||||
}; |
||||
const onCloseNewTagMenu = () => { |
||||
setNewTagMenuPos(null); |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
if (props.state.fetchedTags !== null) { |
||||
return; |
||||
} |
||||
(async () => { |
||||
const allTags = await getAllTags(); |
||||
// We have the tags in list form. Now, we want to organize
|
||||
// them hierarchically by giving each tag a "children" prop.
|
||||
props.dispatch({ |
||||
type: ManageTagsWindowActions.SetFetchedTags, |
||||
value: allTags, |
||||
}); |
||||
})(); |
||||
}, [props.state.fetchedTags]); |
||||
|
||||
const tagsWithChanges = annotateTagsWithChanges(props.state.fetchedTags || {}, props.state.pendingChanges) |
||||
const changedTags = organiseTags( |
||||
applyTagsChanges(props.state.fetchedTags || {}, props.state.pendingChanges), |
||||
null); |
||||
const tags = organiseTags(tagsWithChanges, null); |
||||
|
||||
return <> |
||||
<Box width="100%" justifyContent="center" display="flex" flexWrap="wrap"> |
||||
<Box |
||||
m={1} |
||||
mt={4} |
||||
width="80%" |
||||
> |
||||
<LoyaltyIcon style={{ fontSize: 80 }} /> |
||||
</Box> |
||||
<Box |
||||
m={1} |
||||
mt={4} |
||||
width="80%" |
||||
> |
||||
<Typography variant="h4">Manage Tags</Typography> |
||||
</Box> |
||||
{props.state.pendingChanges.length > 0 && <Box |
||||
m={1} |
||||
mt={4} |
||||
width="80%" |
||||
> |
||||
<ControlTagChanges |
||||
changes={props.state.pendingChanges} |
||||
onDiscard={() => { |
||||
props.dispatch({ |
||||
type: ManageTagsWindowActions.SetPendingChanges, |
||||
value: [], |
||||
}) |
||||
props.dispatch({ |
||||
type: ManageTagsWindowActions.SetAlert, |
||||
value: null, |
||||
}) |
||||
}} |
||||
onSave={() => { |
||||
submitTagChanges(props.state.pendingChanges).then(() => { |
||||
props.dispatch({ |
||||
type: ManageTagsWindowActions.Reset |
||||
}); |
||||
}).catch((e: Error) => { |
||||
props.dispatch({ |
||||
type: ManageTagsWindowActions.SetAlert, |
||||
value: <Alert severity="error">Failed to save changes: {e.message}</Alert>, |
||||
}) |
||||
}) |
||||
}} |
||||
getTagDetails={(id: string) => tagsWithChanges[id]} |
||||
/> |
||||
</Box>} |
||||
{props.state.alert && <Box |
||||
m={1} |
||||
mt={4} |
||||
width="80%" |
||||
>{props.state.alert}</Box>} |
||||
<Box |
||||
m={1} |
||||
mt={4} |
||||
width="80%" |
||||
> |
||||
{tags && tags.length && tags.map((tag: any) => { |
||||
return <SingleTag |
||||
tag={tag} |
||||
prependElems={[]} |
||||
dispatch={props.dispatch} |
||||
mainDispatch={props.mainDispatch} |
||||
state={props.state} |
||||
changedTags={changedTags} |
||||
/>; |
||||
})} |
||||
<Box mt={3}><CreateTagButton onClick={(e: any) => { onOpenNewTagMenu(e) }} /></Box> |
||||
</Box> |
||||
</Box> |
||||
<NewTagMenu |
||||
position={newTagMenuPos} |
||||
open={newTagMenuPos !== null} |
||||
onCreate={(name: string, parentId: string | null) => { |
||||
props.dispatch({ |
||||
type: ManageTagsWindowActions.SetPendingChanges, |
||||
value: [ |
||||
...props.state.pendingChanges, |
||||
{ |
||||
type: TagChangeType.Create, |
||||
id: genUuid(), |
||||
parent: parentId, |
||||
name: name, |
||||
} |
||||
] |
||||
}) |
||||
}} |
||||
onClose={onCloseNewTagMenu} |
||||
changedTags={changedTags} |
||||
/> |
||||
</> |
||||
} |
@ -0,0 +1,54 @@ |
||||
import React, { useState } from 'react'; |
||||
import { Menu, MenuItem, TextField, Input } from '@material-ui/core'; |
||||
import NestedMenuItem from "material-ui-nested-menu-item"; |
||||
import MenuEditText from '../../common/MenuEditText'; |
||||
|
||||
export function PickCreateTag(props: { |
||||
tags: any[], |
||||
open: boolean, |
||||
parentId: string | null, |
||||
onCreate: (name: string, parentId: string | null) => void, |
||||
}) { |
||||
|
||||
return <> |
||||
<MenuEditText |
||||
label="Name" |
||||
onSubmit={(s: string) => { |
||||
props.onCreate(s, props.parentId); |
||||
}} |
||||
/> |
||||
{props.tags.map((tag: any) => { |
||||
return <NestedMenuItem |
||||
parentMenuOpen={props.open} |
||||
label={tag.name} |
||||
> |
||||
<PickCreateTag tags={tag.children} open={props.open} parentId={tag.tagId} onCreate={props.onCreate} /> |
||||
</NestedMenuItem> |
||||
})} |
||||
</> |
||||
} |
||||
|
||||
export default function NewTagMenu(props: { |
||||
position: null | number[], |
||||
open: boolean, |
||||
onCreate: (name: string, parentId: string | null) => void, |
||||
onClose: () => void, |
||||
changedTags: any[], // Tags organized hierarchically with "children" fields
|
||||
}) { |
||||
const pos = props.open && props.position ? |
||||
{ left: props.position[0], top: props.position[1] } |
||||
: { left: 0, top: 0 } |
||||
|
||||
return <Menu |
||||
open={props.open} |
||||
anchorReference="anchorPosition" |
||||
anchorPosition={pos} |
||||
keepMounted |
||||
onClose={props.onClose} |
||||
> |
||||
<PickCreateTag tags={props.changedTags} open={props.open} parentId={null} onCreate={(n: string, v: string | null) => { |
||||
props.onClose(); |
||||
props.onCreate(n, v); |
||||
}} /> |
||||
</Menu> |
||||
} |
@ -0,0 +1,147 @@ |
||||
import React, { useState, useEffect } from 'react'; |
||||
import { Typography, Chip, CircularProgress, Box, Paper } from '@material-ui/core'; |
||||
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, mergeTag } from '../../../lib/backend/tags'; |
||||
|
||||
export enum TagChangeType { |
||||
Delete = "Delete", |
||||
Create = "Create", |
||||
MoveTo = "MoveTo", |
||||
MergeTo = "MergeTo", |
||||
Rename = "Rename", |
||||
} |
||||
|
||||
export interface TagChange { |
||||
type: TagChangeType, |
||||
id: string, // Stringified integer == MuDBase ID. Other string == not yet committed to DB.
|
||||
parent?: string | null, // Stringified integer == MuDBase ID. Other string == not yet committed to DB.
|
||||
// null refers to the tags root.
|
||||
name?: string, |
||||
into?: string, // Used for storing the tag ID to merge into, if applicable. As in the other ID fields.
|
||||
} |
||||
|
||||
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<string, number> = {} |
||||
|
||||
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; |
||||
const intoId = change.into ? getId(change.into) : 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; |
||||
case TagChangeType.MergeTo: |
||||
if (!numericId) { throw new Error("Cannot merge tag with no numeric ID"); } |
||||
if (!intoId) { throw new Error("Cannot merge tag into tag with no numeric ID"); } |
||||
await mergeTag(numericId, intoId); |
||||
break; |
||||
default: |
||||
throw new Error("Unimplemented tag change"); |
||||
} |
||||
} |
||||
} |
||||
|
||||
export function TagChangeDisplay(props: { |
||||
change: TagChange, |
||||
getTagDetails: (id: string) => any, |
||||
}) { |
||||
const tag = props.getTagDetails(props.change.id); |
||||
const oldParent = tag.parentId ? props.getTagDetails(tag.parentId) : null; |
||||
const newParent = props.change.parent ? props.getTagDetails(props.change.parent) : null; |
||||
|
||||
const MakeTag = (props: { name: string }) => <Chip label={props.name} /> |
||||
const MainTag = tag ? |
||||
<MakeTag name={tag.name} /> : |
||||
<CircularProgress />; |
||||
|
||||
switch (props.change.type) { |
||||
case TagChangeType.Delete: |
||||
return <Typography>Delete {MainTag}</Typography> |
||||
case TagChangeType.Rename: |
||||
const NewTag = tag ? |
||||
<MakeTag name={props.change.name || "unknown"} /> : |
||||
<CircularProgress />; |
||||
return <Typography>Rename {MainTag} to {NewTag}</Typography> |
||||
case TagChangeType.MoveTo: |
||||
const OldParent = oldParent !== undefined ? |
||||
<MakeTag name={oldParent ? oldParent.name : "/"} /> : |
||||
<CircularProgress />; |
||||
const NewParent = newParent !== undefined ? |
||||
<MakeTag name={newParent ? newParent.name : "/"} /> : |
||||
<CircularProgress />; |
||||
return <Typography>Move {MainTag} from {OldParent} to {NewParent}</Typography> |
||||
case TagChangeType.MergeTo: |
||||
const intoTag = props.getTagDetails(props.change.into || "unknown"); |
||||
const IntoTag = <MakeTag name={intoTag.name} /> |
||||
return <Typography>Merge {MainTag} into {IntoTag}</Typography> |
||||
case TagChangeType.Create: |
||||
return props.change.parent ? |
||||
<Typography>Create {MainTag} under <MakeTag name={newParent.name} /></Typography> : |
||||
<Typography>Create {MainTag}</Typography> |
||||
default: |
||||
throw new Error("Unhandled tag change type") |
||||
} |
||||
} |
||||
|
||||
export default function ControlTagChanges(props: { |
||||
changes: TagChange[], |
||||
onSave: () => void, |
||||
onDiscard: () => void, |
||||
getTagDetails: (id: string) => any, |
||||
}) { |
||||
return <Box display="flex"><Paper style={{ padding: 10, minWidth: 0 }}> |
||||
<Typography variant="h5">Pending changes</Typography> |
||||
<Box mt={2}> |
||||
{props.changes.map((change: any) => |
||||
<Box display="flex"> |
||||
<Typography>- </Typography> |
||||
<TagChangeDisplay change={change} getTagDetails={props.getTagDetails} /> |
||||
</Box> |
||||
)} |
||||
</Box> |
||||
<Box mt={2} display="flex"> |
||||
<Box m={1}><SubmitChangesButton onClick={props.onSave}>Save Changes</SubmitChangesButton></Box> |
||||
<Box m={1}><DiscardChangesButton onClick={props.onDiscard}>Discard Changes</DiscardChangesButton></Box> |
||||
</Box> |
||||
</Paper></Box> |
||||
} |
@ -0,0 +1,61 @@ |
||||
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)); |
||||
} |
||||
} |
||||
|
||||
export async function mergeTag(fromId: number, toId: number) { |
||||
const requestOpts = { |
||||
method: 'POST', |
||||
}; |
||||
|
||||
const response = await fetch( |
||||
(process.env.REACT_APP_BACKEND || "") + serverApi.MergeTagEndpoint |
||||
.replace(':id', fromId.toString()) |
||||
.replace(':toId', toId.toString()), |
||||
requestOpts |
||||
); |
||||
if (!response.ok) { |
||||
throw new Error("Response to tag merge not OK: " + JSON.stringify(response)); |
||||
} |
||||
} |
@ -0,0 +1,76 @@ |
||||
import * as api from '../../client/src/api'; |
||||
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; |
||||
import Knex from 'knex'; |
||||
|
||||
async function getChildrenRecursive(id: number, trx: any) { |
||||
const directChildren = (await trx.select('id') |
||||
.from('tags') |
||||
.where({ 'parentId': id })).map((r: any) => r.id); |
||||
|
||||
const indirectChildrenPromises = directChildren.map( |
||||
(child: number) => getChildrenRecursive(child, trx) |
||||
); |
||||
const indirectChildrenNested = await Promise.all(indirectChildrenPromises); |
||||
const indirectChildren = indirectChildrenNested.flat(); |
||||
|
||||
return [ |
||||
...directChildren, |
||||
...indirectChildren, |
||||
] |
||||
} |
||||
|
||||
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 =
|
||||
getChildrenRecursive(req.params.id, trx); |
||||
|
||||
// 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]); |
||||
|
||||
// Merge all IDs.
|
||||
const toDelete = [ tag, ...children ]; |
||||
console.log ("deleting tags: ", toDelete); |
||||
|
||||
// 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 and its children.
|
||||
await trx('tags') |
||||
.whereIn('id', toDelete) |
||||
.del(); |
||||
|
||||
// Respond to the request.
|
||||
res.status(200).send(); |
||||
|
||||
} catch (e) { |
||||
catchUnhandledErrors(e); |
||||
trx.rollback(); |
||||
} |
||||
}) |
||||
} |
@ -0,0 +1,73 @@ |
||||
import * as api from '../../client/src/api'; |
||||
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; |
||||
import Knex from 'knex'; |
||||
|
||||
export const MergeTagEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { |
||||
if (!api.checkMergeTagRequest(req)) { |
||||
const e: EndpointError = { |
||||
internalMessage: 'Invalid MergeTag request: ' + JSON.stringify(req.body), |
||||
httpStatus: 400 |
||||
}; |
||||
throw e; |
||||
} |
||||
const reqObject: api.DeleteTagRequest = req.body; |
||||
|
||||
console.log("Merge Tag:", reqObject); |
||||
const fromId = req.params.id; |
||||
const toId = req.params.toId; |
||||
|
||||
await knex.transaction(async (trx) => { |
||||
try { |
||||
// Start retrieving the "from" tag.
|
||||
const fromTagPromise = trx.select('id') |
||||
.from('tags') |
||||
.where({ id: fromId }) |
||||
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) |
||||
|
||||
// Start retrieving the "to" tag.
|
||||
const toTagPromise = trx.select('id') |
||||
.from('tags') |
||||
.where({ id: toId }) |
||||
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) |
||||
|
||||
// Wait for the requests to finish.
|
||||
var [fromTag, toTag] = await Promise.all([fromTagPromise, toTagPromise]); |
||||
|
||||
// Check that we found all objects we need.
|
||||
if (!fromTag || !toTag) { |
||||
const e: EndpointError = { |
||||
internalMessage: 'Source or target tag does not exist for MergeTag request: ' + JSON.stringify(req.body), |
||||
httpStatus: 400 |
||||
}; |
||||
throw e; |
||||
} |
||||
|
||||
// Assign new tag ID to any objects referencing the to-be-merged tag.
|
||||
const cPromise = trx('tags') |
||||
.where({ 'parentId': fromId }) |
||||
.update({ 'parentId': toId }); |
||||
const sPromise = trx('songs_tags') |
||||
.where({ 'tagId': fromId }) |
||||
.update({ 'tagId': toId }); |
||||
const arPromise = trx('artists_tags') |
||||
.where({ 'tagId': fromId }) |
||||
.update({ 'tagId': toId }); |
||||
const alPromise = trx('albums_tags') |
||||
.where({ 'tagId': fromId }) |
||||
.update({ 'tagId': toId }); |
||||
await Promise.all([sPromise, arPromise, alPromise, cPromise]); |
||||
|
||||
// Delete the original tag.
|
||||
await trx('tags') |
||||
.where({ 'id': fromId }) |
||||
.del(); |
||||
|
||||
// Respond to the request.
|
||||
res.status(200).send(); |
||||
|
||||
} catch (e) { |
||||
catchUnhandledErrors(e); |
||||
trx.rollback(); |
||||
} |
||||
}) |
||||
} |
Loading…
Reference in new issue