Merge pull request 'Detail pages' (#20) from edit_page into master

Reviewed-on: #20
pull/21/head^2
Sander Vocke 5 years ago
commit 8f4157add4
  1. 3
      client/src/api.ts
  2. 99
      client/src/components/MainWindow.tsx
  3. 32
      client/src/components/appbar/AddTabMenu.tsx
  4. 57
      client/src/components/appbar/AppBar.tsx
  5. 2
      client/src/components/querybuilder/QBAddElemMenu.tsx
  6. 103
      client/src/components/tables/ResultsTable.tsx
  7. 101
      client/src/components/windows/AlbumWindow.tsx
  8. 101
      client/src/components/windows/ArtistWindow.tsx
  9. 30
      client/src/components/windows/QueryWindow.tsx
  10. 101
      client/src/components/windows/SongWindow.tsx
  11. 101
      client/src/components/windows/TagWindow.tsx
  12. 70
      client/src/components/windows/Windows.tsx
  13. 4
      server/endpoints/QueryEndpointHandler.ts
  14. 16
      server/test/integration/flows/QueryFlow.js

@ -81,6 +81,7 @@ export enum QueryElemProperty {
artistName = "artistName", artistName = "artistName",
artistId = "artistId", artistId = "artistId",
albumName = "albumName", albumName = "albumName",
albumId = "albumId",
tagId = "tagId", tagId = "tagId",
} }
export enum OrderByType { export enum OrderByType {
@ -130,7 +131,7 @@ export function checkQueryElem(elem: any): boolean {
}); });
} }
return (elem.childrenOperator && elem.children) || return (elem.childrenOperator && elem.children) ||
(elem.prop && elem.propOperand && elem.propOperator) || ("prop" in elem && "propOperand" in elem && "propOperator" in elem) ||
Object.keys(elem).length === 0; Object.keys(elem).length === 0;
} }
export function checkQueryRequest(req: any): boolean { export function checkQueryRequest(req: any): boolean {

@ -1,8 +1,14 @@
import React, { useReducer, useState, Reducer } from 'react'; import React, { useReducer, useState, Reducer } from 'react';
import { ThemeProvider, CssBaseline, createMuiTheme } from '@material-ui/core'; import { ThemeProvider, CssBaseline, createMuiTheme, withWidth } from '@material-ui/core';
import { grey } from '@material-ui/core/colors'; import { grey } from '@material-ui/core/colors';
import AppBar from './appbar/AppBar'; import AppBar from './appbar/AppBar';
import QueryWindow, { QueryWindowReducer, QueryWindowState } from './windows/QueryWindow'; import QueryWindow from './windows/QueryWindow';
import { NewTabProps } from './appbar/AddTabMenu';
import { newWindowState, newWindowReducer, WindowType } from './windows/Windows';
import ArtistWindow from './windows/ArtistWindow';
import AlbumWindow from './windows/AlbumWindow';
import TagWindow from './windows/TagWindow';
import SongWindow from './windows/SongWindow';
var _ = require('lodash'); var _ = require('lodash');
const darkTheme = createMuiTheme({ const darkTheme = createMuiTheme({
@ -15,9 +21,9 @@ const darkTheme = createMuiTheme({
}); });
export interface MainWindowState { export interface MainWindowState {
tabLabels: string[],
tabStates: any[], tabStates: any[],
tabReducers: Reducer<any, any>[], tabReducers: Reducer<any, any>[],
tabTypes: WindowType[],
activeTab: number, activeTab: number,
} }
@ -37,16 +43,17 @@ export function MainWindowReducer(state: MainWindowState, action: any) {
return { return {
...state, ...state,
tabStates: state.tabStates.filter((i: any, idx: number) => idx != action.idx), tabStates: state.tabStates.filter((i: any, idx: number) => idx != action.idx),
tabLabels: state.tabLabels.filter((i: any, idx: number) => idx != action.idx),
tabReducers: state.tabReducers.filter((i: any, idx: number) => idx != action.idx), tabReducers: state.tabReducers.filter((i: any, idx: number) => idx != action.idx),
activeTab: state.activeTab >= (newSize-1) ? (newSize-1) : state.activeTab, tabTypes: state.tabTypes.filter((i: any, idx: number) => idx != action.idx),
activeTab: state.activeTab >= (newSize - 1) ? (newSize - 1) : state.activeTab,
} }
case MainWindowStateActions.AddTab: case MainWindowStateActions.AddTab:
console.log("Add tab: ", action)
return { return {
...state, ...state,
tabStates: [...state.tabStates, action.tabState], tabStates: [...state.tabStates, action.tabState],
tabLabels: [...state.tabLabels, action.tabLabel],
tabReducers: [...state.tabReducers, action.tabReducer], tabReducers: [...state.tabReducers, action.tabReducer],
tabTypes: [...state.tabTypes, action.tabType],
} }
case MainWindowStateActions.DispatchToTab: case MainWindowStateActions.DispatchToTab:
return { return {
@ -64,51 +71,75 @@ export function MainWindowReducer(state: MainWindowState, action: any) {
export default function MainWindow(props: any) { export default function MainWindow(props: any) {
const [state, dispatch] = useReducer(MainWindowReducer, { const [state, dispatch] = useReducer(MainWindowReducer, {
tabLabels: ["Query"],
tabStates: [ tabStates: [
{ newWindowState[WindowType.Query]()
editingQuery: false,
query: null,
resultsForQuery: null,
},
], ],
tabReducers: [QueryWindowReducer, QueryWindowReducer], tabReducers: [newWindowReducer[WindowType.Query]],
tabTypes: [WindowType.Query],
activeTab: 0 activeTab: 0
}) })
const queryWindows = state.tabStates.map((state: QueryWindowState, i: number) => { const windows = state.tabStates.map((tabState: any, i: number) => {
return <QueryWindow const tabDispatch = (action: any) => {
state={state} dispatch({
dispatch={(action: any) => { type: MainWindowStateActions.DispatchToTab,
dispatch({ tabAction: action,
type: MainWindowStateActions.DispatchToTab, idx: i
tabAction: action, });
idx: i }
});
}} switch (state.tabTypes[i]) {
/> case WindowType.Query:
return <QueryWindow
state={tabState}
dispatch={tabDispatch}
mainDispatch={dispatch}
/>
case WindowType.Artist:
return <ArtistWindow
state={tabState}
dispatch={tabDispatch}
mainDispatch={dispatch}
/>
case WindowType.Album:
return <AlbumWindow
state={tabState}
dispatch={tabDispatch}
mainDispatch={dispatch}
/>
case WindowType.Tag:
return <TagWindow
state={tabState}
dispatch={tabDispatch}
mainDispatch={dispatch}
/>
case WindowType.Song:
return <SongWindow
state={tabState}
dispatch={tabDispatch}
mainDispatch={dispatch}
/>
default:
throw new Error("Unimplemented window type");
}
}); });
return <ThemeProvider theme={darkTheme}> return <ThemeProvider theme={darkTheme}>
<CssBaseline /> <CssBaseline />
<AppBar <AppBar
tabLabels={state.tabLabels} tabLabels={state.tabStates.map((s: any) => s.tabLabel)}
selectedTab={state.activeTab} selectedTab={state.activeTab}
setSelectedTab={(t: number) => dispatch({ type: MainWindowStateActions.SetActiveTab, value: t })} setSelectedTab={(t: number) => dispatch({ type: MainWindowStateActions.SetActiveTab, value: t })}
onCloseTab={(t: number) => dispatch({ type: MainWindowStateActions.CloseTab, idx: t })} onCloseTab={(t: number) => dispatch({ type: MainWindowStateActions.CloseTab, idx: t })}
onAddTab={() => { onAddTab={(w: NewTabProps) => {
dispatch({ dispatch({
type: MainWindowStateActions.AddTab, type: MainWindowStateActions.AddTab,
tabState: { tabState: newWindowState[w.windowType](),
editingQuery: false, tabReducer: newWindowReducer[w.windowType],
query: null, tabType: w.windowType,
resultsForQuery: null,
},
tabLabel: "Query",
tabReducer: QueryWindowReducer,
}) })
}} }}
/> />
{queryWindows[state.activeTab]} {windows[state.activeTab]}
</ThemeProvider> </ThemeProvider>
} }

@ -0,0 +1,32 @@
import React from 'react';
import { WindowType } from '../windows/Windows';
import { Menu, MenuItem } from '@material-ui/core';
export interface NewTabProps {
windowType: WindowType,
}
export interface IProps {
anchorEl: null | HTMLElement,
onClose: () => void,
onCreateTab: (q: NewTabProps) => void,
}
export default function AddTabMenu(props: IProps) {
return <Menu
anchorEl={props.anchorEl}
keepMounted
open={Boolean(props.anchorEl)}
onClose={props.onClose}
>
<MenuItem disabled={true}>New Tab</MenuItem>
<MenuItem
onClick={() => {
props.onClose();
props.onCreateTab({
windowType: WindowType.Query,
})
}}
>{WindowType.Query}</MenuItem>
</Menu>
}

@ -2,13 +2,14 @@ import React, { useState } from 'react';
import { AppBar as MuiAppBar, Box, Tab as MuiTab, Tabs, IconButton } from '@material-ui/core'; import { AppBar as MuiAppBar, Box, Tab as MuiTab, Tabs, IconButton } from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close'; import CloseIcon from '@material-ui/icons/Close';
import AddIcon from '@material-ui/icons/Add'; import AddIcon from '@material-ui/icons/Add';
import AddTabMenu, { NewTabProps } from './AddTabMenu';
export interface IProps { export interface IProps {
tabLabels: string[], tabLabels: string[],
selectedTab: number, selectedTab: number,
setSelectedTab: (n: number) => void, setSelectedTab: (n: number) => void,
onCloseTab: (idx: number) => void, onCloseTab: (idx: number) => void,
onAddTab: () => void, onAddTab: (w: NewTabProps) => void,
} }
export interface TabProps { export interface TabProps {
@ -17,7 +18,6 @@ export interface TabProps {
export function Tab(props: any) { export function Tab(props: any) {
const { onClose, label, ...restProps } = props; const { onClose, label, ...restProps } = props;
const [hover, setHover] = useState<boolean>(false);
const labelElem = <Box const labelElem = <Box
display="flex" display="flex"
@ -25,7 +25,7 @@ export function Tab(props: any) {
justifyContent="center" justifyContent="center"
> >
{label} {label}
{hover && <Box ml={1}> <Box ml={1}>
<IconButton <IconButton
size="small" size="small"
color="inherit" color="inherit"
@ -33,31 +33,48 @@ export function Tab(props: any) {
> >
<CloseIcon /> <CloseIcon />
</IconButton> </IconButton>
</Box>} </Box>
</Box>; </Box>;
return <MuiTab return <MuiTab
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
label={labelElem} label={labelElem}
{...restProps} {...restProps}
/> />
} }
export default function AppBar(props: IProps) { export default function AppBar(props: IProps) {
return <MuiAppBar position="static" style={{ background: 'grey' }}> const [addMenuAnchorEl, setAddMenuAnchorEl] = React.useState<null | HTMLElement>(null);
<Box display="flex" alignItems="center">
<Box m={0.5} display="flex" alignItems="center"> const onOpenAddMenu = (event: any) => {
<img height="30px" src={process.env.PUBLIC_URL + "/logo.svg"} alt="error"></img> setAddMenuAnchorEl(event.currentTarget);
};
const onCloseAddMenu = () => {
setAddMenuAnchorEl(null);
};
const onAddTab = (w: NewTabProps) => {
props.onAddTab(w);
};
return <>
<MuiAppBar position="static" style={{ background: 'grey' }}>
<Box display="flex" alignItems="center">
<Box m={0.5} display="flex" alignItems="center">
<img height="30px" src={process.env.PUBLIC_URL + "/logo.svg"} alt="error"></img>
</Box>
<Tabs value={props.selectedTab} onChange={(e: any, v: number) => props.setSelectedTab(v)}>
{props.tabLabels.map((l: string, idx: number) => <Tab
label={l}
value={idx}
onClose={() => props.onCloseTab(idx)}
/>)}
</Tabs>
<IconButton color="inherit" onClick={onOpenAddMenu}><AddIcon /></IconButton>
</Box> </Box>
<Tabs value={props.selectedTab} onChange={(e: any, v: number) => props.setSelectedTab(v)}> </MuiAppBar>
{props.tabLabels.map((l: string, idx: number) => <Tab <AddTabMenu
label={l} anchorEl={addMenuAnchorEl}
value={idx} onClose={onCloseAddMenu}
onClose={() => props.onCloseTab(idx)} onCreateTab={onAddTab}
/>)} />
</Tabs> </>
<IconButton color="inherit" onClick={props.onAddTab}><AddIcon/></IconButton>
</Box>
</MuiAppBar>
} }

@ -3,7 +3,7 @@ import { Menu, MenuItem } from '@material-ui/core';
import NestedMenuItem from "material-ui-nested-menu-item"; import NestedMenuItem from "material-ui-nested-menu-item";
import { QueryElem, QueryLeafBy, QueryLeafOp, TagQueryInfo } from '../../lib/query/Query'; import { QueryElem, QueryLeafBy, QueryLeafOp, TagQueryInfo } from '../../lib/query/Query';
import QBSelectWithRequest from './QBSelectWithRequest'; import QBSelectWithRequest from './QBSelectWithRequest';
import { Requests, TagItem } from './QueryBuilder'; import { Requests } from './QueryBuilder';
export interface MenuProps { export interface MenuProps {
anchorEl: null | HTMLElement, anchorEl: null | HTMLElement,

@ -1,17 +1,28 @@
import React from 'react'; import React from 'react';
import { TableContainer, Table, TableHead, TableRow, TableCell, Paper, makeStyles, TableBody, Chip, Box, Button } from '@material-ui/core'; import { TableContainer, Table, TableHead, TableRow, TableCell, Paper, makeStyles, TableBody, Chip, Box, Button } from '@material-ui/core';
import stringifyList from '../../lib/stringifyList'; import stringifyList from '../../lib/stringifyList';
import { MainWindowStateActions } from '../MainWindow';
import { newWindowReducer, WindowType } from '../windows/Windows';
import PersonIcon from '@material-ui/icons/Person';
import AlbumIcon from '@material-ui/icons/Album';
import AudiotrackIcon from '@material-ui/icons/Audiotrack';
import LocalOfferIcon from '@material-ui/icons/LocalOffer';
export interface SongGetters { export interface SongGetters {
getTitle: (song: any) => string, getTitle: (song: any) => string,
getArtist: (song: any) => string, getId: (song: any) => number,
getAlbum: (song: any) => string, getArtistNames: (song: any) => string[],
getTags: (song: any) => string[][], // Each tag is represented as a series of strings. getArtistIds: (song: any) => number[],
getAlbumNames: (song: any) => string[],
getAlbumIds: (song: any) => number[],
getTagNames: (song: any) => string[][], // Each tag is represented as a series of strings.
getTagIds: (song: any) => number[][], // Each tag is represented as a series of ids.
} }
export interface IProps { export interface IProps {
songs: any[], songs: any[],
songGetters: SongGetters, songGetters: SongGetters,
mainDispatch: (action: any) => void,
} }
export function SongTable(props: IProps) { export function SongTable(props: IProps) {
@ -36,13 +47,79 @@ export function SongTable(props: IProps) {
<TableBody> <TableBody>
{props.songs.map((song: any) => { {props.songs.map((song: any) => {
const title = props.songGetters.getTitle(song); const title = props.songGetters.getTitle(song);
const artist = props.songGetters.getArtist(song); // TODO / FIXME: display artists and albums separately!
const album = props.songGetters.getAlbum(song); const artistNames = props.songGetters.getArtistNames(song);
const tags = props.songGetters.getTags(song).map((tag: string[]) => { const artist = stringifyList(artistNames);
const mainArtistId = props.songGetters.getArtistIds(song)[0];
const mainArtistName = artistNames[0];
const albumNames = props.songGetters.getAlbumNames(song);
const album = stringifyList(albumNames);
const mainAlbumName = albumNames[0];
const mainAlbumId = props.songGetters.getAlbumIds(song)[0];
const songId = props.songGetters.getId(song);
const tagIds = props.songGetters.getTagIds(song);
const onClickArtist = () => {
props.mainDispatch({
type: MainWindowStateActions.AddTab,
tabState: {
tabLabel: <><PersonIcon />{mainArtistName}</>,
artistId: mainArtistId,
metadata: null,
},
tabReducer: newWindowReducer[WindowType.Artist],
tabType: WindowType.Artist,
})
}
const onClickAlbum = () => {
props.mainDispatch({
type: MainWindowStateActions.AddTab,
tabState: {
tabLabel: <><AlbumIcon />{mainAlbumName}</>,
albumId: mainAlbumId,
metadata: null,
},
tabReducer: newWindowReducer[WindowType.Album],
tabType: WindowType.Album,
})
}
const onClickSong = () => {
props.mainDispatch({
type: MainWindowStateActions.AddTab,
tabState: {
tabLabel: <><AudiotrackIcon />{title}</>,
songId: songId,
metadata: null,
},
tabReducer: newWindowReducer[WindowType.Song],
tabType: WindowType.Song,
})
}
const onClickTag = (id: number, name: string) => {
props.mainDispatch({
type: MainWindowStateActions.AddTab,
tabState: {
tabLabel: <><LocalOfferIcon />{name}</>,
tagId: id,
metadata: null,
},
tabReducer: newWindowReducer[WindowType.Tag],
tabType: WindowType.Tag,
})
}
const tags = props.songGetters.getTagNames(song).map((tag: string[], i: number) => {
const fullTag = stringifyList(tag, undefined, (idx: number, e: string) => {
return (idx === 0) ? e : " / " + e;
})
return <Box ml={0.5} mr={0.5}> return <Box ml={0.5} mr={0.5}>
<Chip size="small" label={stringifyList(tag, undefined, (idx: number, e: string) => { <Chip size="small"
return (idx === 0) ? e : " / " + e; label={fullTag}
})} /> onClick={() => onClickTag(tagIds[i][tagIds[i].length-1], fullTag)}
/>
</Box> </Box>
}); });
@ -56,7 +133,7 @@ export function SongTable(props: IProps) {
} }
})(); })();
return <TableCell padding="none" {...props}> return <TableCell padding="none" {...props}>
<Button className={classes.button} fullWidth={true}> <Button className={classes.button} fullWidth={true} onClick={props._onClick}>
<Box <Box
width="100%" width="100%"
display="flex" display="flex"
@ -70,9 +147,9 @@ export function SongTable(props: IProps) {
} }
return <TableRow key={title}> return <TableRow key={title}>
<TextCell align="left">{title}</TextCell> <TextCell align="left" _onClick={onClickSong}>{title}</TextCell>
<TextCell align="left">{artist}</TextCell> <TextCell align="left" _onClick={onClickArtist}>{artist}</TextCell>
<TextCell align="left">{album}</TextCell> <TextCell align="left" _onClick={onClickAlbum}>{album}</TextCell>
<TableCell padding="none" align="left" width="25%"> <TableCell padding="none" align="left" width="25%">
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
{tags} {tags}

@ -0,0 +1,101 @@
import React, { useEffect } from 'react';
import { Box, Typography } from '@material-ui/core';
import AlbumIcon from '@material-ui/icons/Album';
import * as serverApi from '../../api';
import { WindowState } from './Windows';
export interface AlbumMetadata {
name: string,
}
export interface AlbumWindowState extends WindowState {
albumId: number,
metadata: AlbumMetadata | null,
}
export enum AlbumWindowStateActions {
SetMetadata = "SetMetadata",
}
export function AlbumWindowReducer(state: AlbumWindowState, action: any) {
switch (action.type) {
case AlbumWindowStateActions.SetMetadata:
return { ...state, metadata: action.value }
default:
throw new Error("Unimplemented AlbumWindow state update.")
}
}
export interface IProps {
state: AlbumWindowState,
dispatch: (action: any) => void,
mainDispatch: (action: any) => void,
}
export async function getAlbumMetadata(id: number) {
const query = {
prop: serverApi.QueryElemProperty.albumId,
propOperand: id,
propOperator: serverApi.QueryFilterOp.Eq,
};
var q: serverApi.QueryRequest = {
query: query,
offsetsLimits: {
albumOffset: 0,
albumLimit: 1,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
let album = json.albums[0];
return {
name: album.name
}
})();
}
export default function AlbumWindow(props: IProps) {
let metadata = props.state.metadata;
useEffect(() => {
getAlbumMetadata(props.state.albumId)
.then((m: AlbumMetadata) => {
console.log("metadata", m);
props.dispatch({
type: AlbumWindowStateActions.SetMetadata,
value: m
});
})
}, [props.state.metadata?.name]);
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="80%"
>
<AlbumIcon style={{ fontSize: 80 }}/>
</Box>
<Box
m={1}
width="80%"
>
{metadata && <Typography variant="h4">{metadata.name}</Typography>}
</Box>
</Box>
}

@ -0,0 +1,101 @@
import React, { useEffect } from 'react';
import { Box, Typography } from '@material-ui/core';
import PersonIcon from '@material-ui/icons/Person';
import * as serverApi from '../../api';
import { WindowState } from './Windows';
export interface ArtistMetadata {
name: string,
}
export interface ArtistWindowState extends WindowState {
artistId: number,
metadata: ArtistMetadata | null,
}
export enum ArtistWindowStateActions {
SetMetadata = "SetMetadata",
}
export function ArtistWindowReducer(state: ArtistWindowState, action: any) {
switch (action.type) {
case ArtistWindowStateActions.SetMetadata:
return { ...state, metadata: action.value }
default:
throw new Error("Unimplemented ArtistWindow state update.")
}
}
export interface IProps {
state: ArtistWindowState,
dispatch: (action: any) => void,
mainDispatch: (action: any) => void,
}
export async function getArtistMetadata(id: number) {
const query = {
prop: serverApi.QueryElemProperty.artistId,
propOperand: id,
propOperator: serverApi.QueryFilterOp.Eq,
};
var q: serverApi.QueryRequest = {
query: query,
offsetsLimits: {
artistOffset: 0,
artistLimit: 1,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
let artist = json.artists[0];
return {
name: artist.name
}
})();
}
export default function ArtistWindow(props: IProps) {
let metadata = props.state.metadata;
useEffect(() => {
getArtistMetadata(props.state.artistId)
.then((m: ArtistMetadata) => {
console.log("metadata", m);
props.dispatch({
type: ArtistWindowStateActions.SetMetadata,
value: m
});
})
}, [props.state.metadata?.name]);
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="80%"
>
<PersonIcon style={{ fontSize: 80 }}/>
</Box>
<Box
m={1}
width="80%"
>
{metadata && <Typography variant="h4">{metadata.name}</Typography>}
</Box>
</Box>
}

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useEffect } from 'react';
import { createMuiTheme, Box, LinearProgress } from '@material-ui/core'; import { createMuiTheme, Box, LinearProgress } from '@material-ui/core';
import { QueryElem, toApiQuery } from '../../lib/query/Query'; import { QueryElem, toApiQuery } from '../../lib/query/Query';
import QueryBuilder from '../querybuilder/QueryBuilder'; import QueryBuilder from '../querybuilder/QueryBuilder';
@ -7,6 +7,7 @@ import { SongTable } from '../tables/ResultsTable';
import stringifyList from '../../lib/stringifyList'; import stringifyList from '../../lib/stringifyList';
import { getArtists, getSongTitles, getAlbums, getTags } from '../../lib/query/Getters'; import { getArtists, getSongTitles, getAlbums, getTags } from '../../lib/query/Getters';
import { grey } from '@material-ui/core/colors'; import { grey } from '@material-ui/core/colors';
import { WindowState } from './Windows';
var _ = require('lodash'); var _ = require('lodash');
const darkTheme = createMuiTheme({ const darkTheme = createMuiTheme({
@ -23,7 +24,7 @@ export interface ResultsForQuery {
results: any[], results: any[],
}; };
export interface QueryWindowState { export interface QueryWindowState extends WindowState {
editingQuery: boolean, editingQuery: boolean,
query: QueryElem | null, query: QueryElem | null,
resultsForQuery: ResultsForQuery | null, resultsForQuery: ResultsForQuery | null,
@ -50,7 +51,8 @@ export function QueryWindowReducer(state: QueryWindowState, action: any) {
export interface IProps { export interface IProps {
state: QueryWindowState, state: QueryWindowState,
dispatch: (action: any) => void dispatch: (action: any) => void,
mainDispatch: (action: any) => void,
} }
export default function QueryWindow(props: IProps) { export default function QueryWindow(props: IProps) {
@ -72,9 +74,12 @@ export default function QueryWindow(props: IProps) {
const songGetters = { const songGetters = {
getTitle: (song: any) => song.title, getTitle: (song: any) => song.title,
getArtist: (song: any) => stringifyList(song.artists, (a: any) => a.name), getId: (song: any) => song.songId,
getAlbum: (song: any) => stringifyList(song.albums, (a: any) => a.name), getArtistNames: (song: any) => song.artists.map((a: any) => a.name),
getTags: (song: any) => { getArtistIds: (song: any) => song.artists.map((a: any) => a.artistId),
getAlbumNames: (song: any) => song.albums.map((a: any) => a.name),
getAlbumIds: (song: any) => song.albums.map((a: any) => a.albumId),
getTagNames: (song: any) => {
// Recursively resolve the name. // Recursively resolve the name.
const resolveTag = (tag: any) => { const resolveTag = (tag: any) => {
var r = [tag.name]; var r = [tag.name];
@ -83,7 +88,17 @@ export default function QueryWindow(props: IProps) {
} }
return song.tags.map((tag: any) => resolveTag(tag)); return song.tags.map((tag: any) => resolveTag(tag));
} },
getTagIds: (song: any) => {
// Recursively resolve the id.
const resolveTag = (tag: any) => {
var r = [tag.tagId];
if (tag.parent) { r.unshift(resolveTag(tag.parent)); }
return r;
}
return song.tags.map((tag: any) => resolveTag(tag));
},
} }
const doQuery = async (_query: QueryElem) => { const doQuery = async (_query: QueryElem) => {
@ -152,6 +167,7 @@ export default function QueryWindow(props: IProps) {
<SongTable <SongTable
songs={showResults} songs={showResults}
songGetters={songGetters} songGetters={songGetters}
mainDispatch={props.mainDispatch}
/> />
{loading && <LinearProgress />} {loading && <LinearProgress />}
</Box> </Box>

@ -0,0 +1,101 @@
import React, { useEffect } from 'react';
import { Box, Typography } from '@material-ui/core';
import AudiotrackIcon from '@material-ui/icons/Audiotrack';
import * as serverApi from '../../api';
import { WindowState } from './Windows';
export interface SongMetadata {
title: string,
}
export interface SongWindowState extends WindowState {
songId: number,
metadata: SongMetadata | null,
}
export enum SongWindowStateActions {
SetMetadata = "SetMetadata",
}
export function SongWindowReducer(state: SongWindowState, action: any) {
switch (action.type) {
case SongWindowStateActions.SetMetadata:
return { ...state, metadata: action.value }
default:
throw new Error("Unimplemented SongWindow state update.")
}
}
export interface IProps {
state: SongWindowState,
dispatch: (action: any) => void,
mainDispatch: (action: any) => void,
}
export async function getSongMetadata(id: number) {
const query = {
prop: serverApi.QueryElemProperty.songId,
propOperand: id,
propOperator: serverApi.QueryFilterOp.Eq,
};
var q: serverApi.QueryRequest = {
query: query,
offsetsLimits: {
songOffset: 0,
songLimit: 1,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
let song = json.songs[0];
return {
title: song.title
}
})();
}
export default function SongWindow(props: IProps) {
let metadata = props.state.metadata;
useEffect(() => {
getSongMetadata(props.state.songId)
.then((m: SongMetadata) => {
console.log("metadata", m);
props.dispatch({
type: SongWindowStateActions.SetMetadata,
value: m
});
})
}, [props.state.metadata?.title]);
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="80%"
>
<AudiotrackIcon style={{ fontSize: 80 }}/>
</Box>
<Box
m={1}
width="80%"
>
{metadata && <Typography variant="h4">{metadata.title}</Typography>}
</Box>
</Box>
}

@ -0,0 +1,101 @@
import React, { useEffect } from 'react';
import { Box, Typography } from '@material-ui/core';
import LocalOfferIcon from '@material-ui/icons/LocalOffer';
import * as serverApi from '../../api';
import { WindowState } from './Windows';
export interface TagMetadata {
name: string,
}
export interface TagWindowState extends WindowState {
tagId: number,
metadata: TagMetadata | null,
}
export enum TagWindowStateActions {
SetMetadata = "SetMetadata",
}
export function TagWindowReducer(state: TagWindowState, action: any) {
switch (action.type) {
case TagWindowStateActions.SetMetadata:
return { ...state, metadata: action.value }
default:
throw new Error("Unimplemented TagWindow state update.")
}
}
export interface IProps {
state: TagWindowState,
dispatch: (action: any) => void,
mainDispatch: (action: any) => void,
}
export async function getTagMetadata(id: number) {
const query = {
prop: serverApi.QueryElemProperty.tagId,
propOperand: id,
propOperator: serverApi.QueryFilterOp.Eq,
};
var q: serverApi.QueryRequest = {
query: query,
offsetsLimits: {
tagOffset: 0,
tagLimit: 1,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
let tag = json.tags[0];
return {
name: tag.name
}
})();
}
export default function TagWindow(props: IProps) {
let metadata = props.state.metadata;
useEffect(() => {
getTagMetadata(props.state.tagId)
.then((m: TagMetadata) => {
console.log("metadata", m);
props.dispatch({
type: TagWindowStateActions.SetMetadata,
value: m
});
})
}, [props.state.metadata?.name]);
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="80%"
>
<LocalOfferIcon style={{ fontSize: 80 }}/>
</Box>
<Box
m={1}
width="80%"
>
{metadata && <Typography variant="h4">{metadata.name}</Typography>}
</Box>
</Box>
}

@ -0,0 +1,70 @@
import React from 'react';
import { QueryWindowReducer } from "./QueryWindow";
import { ArtistWindowReducer } from "./ArtistWindow";
import SearchIcon from '@material-ui/icons/Search';
import PersonIcon from '@material-ui/icons/Person';
import AlbumIcon from '@material-ui/icons/Album';
import LocalOfferIcon from '@material-ui/icons/LocalOffer';
import AudiotrackIcon from '@material-ui/icons/Audiotrack';
import { SongWindowReducer } from './SongWindow';
import { AlbumWindowReducer } from './AlbumWindow';
import { TagWindowReducer } from './TagWindow';
export enum WindowType {
Query = "Query",
Artist = "Artist",
Album = "Album",
Tag = "Tag",
Song = "Song",
}
export interface WindowState {
tabLabel: string,
}
export const newWindowReducer = {
[WindowType.Query]: QueryWindowReducer,
[WindowType.Artist]: ArtistWindowReducer,
[WindowType.Album]: AlbumWindowReducer,
[WindowType.Song]: SongWindowReducer,
[WindowType.Tag]: TagWindowReducer,
}
export const newWindowState = {
[WindowType.Query]: () => {
return {
tabLabel: <><SearchIcon/>Query</>,
editingQuery: false,
query: null,
resultsForQuery: null,
};
},
[WindowType.Artist]: () => {
return {
tabLabel: <><PersonIcon/>Artist</>,
artistId: 1,
metadata: null,
}
},
[WindowType.Album]: () => {
return {
tabLabel: <><AlbumIcon/>Album</>,
albumId: 1,
metadata: null,
}
},
[WindowType.Song]: () => {
return {
tabLabel: <><AudiotrackIcon/>Song</>,
songId: 1,
metadata: null,
}
},
[WindowType.Tag]: () => {
return {
tabLabel: <><LocalOfferIcon/>Tag</>,
tagId: 1,
metadata: null,
}
},
}

@ -14,6 +14,7 @@ enum ObjectType {
// To keep track of which database objects are needed to filter on // To keep track of which database objects are needed to filter on
// certain properties. // certain properties.
const propertyObjects: Record<api.QueryElemProperty, ObjectType> = { const propertyObjects: Record<api.QueryElemProperty, ObjectType> = {
[api.QueryElemProperty.albumId]: ObjectType.Album,
[api.QueryElemProperty.albumName]: ObjectType.Album, [api.QueryElemProperty.albumName]: ObjectType.Album,
[api.QueryElemProperty.artistId]: ObjectType.Artist, [api.QueryElemProperty.artistId]: ObjectType.Artist,
[api.QueryElemProperty.artistName]: ObjectType.Artist, [api.QueryElemProperty.artistName]: ObjectType.Artist,
@ -101,6 +102,7 @@ function addLeafWhere(knexQuery: any, queryElem: api.QueryElem, type: WhereType)
[api.QueryElemProperty.artistName]: 'artists.name', [api.QueryElemProperty.artistName]: 'artists.name',
[api.QueryElemProperty.artistId]: 'artists.id', [api.QueryElemProperty.artistId]: 'artists.id',
[api.QueryElemProperty.albumName]: 'albums.name', [api.QueryElemProperty.albumName]: 'albums.name',
[api.QueryElemProperty.albumId]: 'albums.id',
[api.QueryElemProperty.tagId]: 'tags.id', [api.QueryElemProperty.tagId]: 'tags.id',
} }
@ -377,6 +379,8 @@ export const QueryEndpointHandler: EndpointHandler = async (req: any, res: any,
}), }),
} }
console.log("Query repsonse", response);
res.send(response); res.send(response);
} catch (e) { } catch (e) {
catchUnhandledErrors(e); catchUnhandledErrors(e);

@ -37,6 +37,7 @@ describe('POST /query with no songs', () => {
songs: [], songs: [],
tags: [], tags: [],
artists: [], artists: [],
albums: [],
}); });
done(); done();
}); });
@ -111,7 +112,8 @@ describe('POST /query with several songs and filters', () => {
expect(res.body).to.deep.equal({ expect(res.body).to.deep.equal({
songs: [ song1, song2, song3 ], songs: [ song1, song2, song3 ],
artists: [], artists: [],
tags: [] tags: [],
albums: [],
}); });
}); });
} }
@ -141,7 +143,8 @@ describe('POST /query with several songs and filters', () => {
expect(res.body).to.deep.equal({ expect(res.body).to.deep.equal({
songs: [ song1, song3 ], songs: [ song1, song3 ],
artists: [], artists: [],
tags: [] tags: [],
albums: [],
}); });
}); });
} }
@ -171,7 +174,8 @@ describe('POST /query with several songs and filters', () => {
expect(res.body).to.deep.equal({ expect(res.body).to.deep.equal({
songs: [ song2 ], songs: [ song2 ],
artists: [], artists: [],
tags: [] tags: [],
albums: [],
}); });
}); });
} }
@ -201,7 +205,8 @@ describe('POST /query with several songs and filters', () => {
expect(res.body).to.deep.equal({ expect(res.body).to.deep.equal({
songs: [ song1, song2 ], songs: [ song1, song2 ],
artists: [], artists: [],
tags: [] tags: [],
albums: [],
}); });
}); });
} }
@ -241,7 +246,8 @@ describe('POST /query with several songs and filters', () => {
expect(res.body).to.deep.equal({ expect(res.body).to.deep.equal({
songs: [ song1, song3 ], songs: [ song1, song3 ],
artists: [], artists: [],
tags: [] tags: [],
albums: [],
}); });
}); });
} }

Loading…
Cancel
Save