diff --git a/client/src/components/Mockup.tsx b/client/src/components/Mockup.tsx index d27f363..3d71382 100644 --- a/client/src/components/Mockup.tsx +++ b/client/src/components/Mockup.tsx @@ -9,7 +9,7 @@ import TableRow from '@material-ui/core/TableRow'; import Paper from '@material-ui/core/Paper'; import Box from '@material-ui/core/Box'; import SearchIcon from '@material-ui/icons/Search'; -import { IconButton, Icon, Typography, Chip, Link, AppBar, CssBaseline } from '@material-ui/core'; +import { IconButton, Typography, Chip, Link, AppBar, CssBaseline } from '@material-ui/core'; export function MockTable() { @@ -108,7 +108,7 @@ function MockupBody() { return <> - + error diff --git a/client/src/components/Window.tsx b/client/src/components/Window.tsx index 61c0678..0df4391 100644 --- a/client/src/components/Window.tsx +++ b/client/src/components/Window.tsx @@ -2,6 +2,8 @@ import React, { useState } from 'react'; import { ThemeProvider, CssBaseline, createMuiTheme } from '@material-ui/core'; import { QueryElem } from '../lib/query/Query'; import QueryBuilder from './querybuilder/QueryBuilder'; +import * as serverApi from '../api'; +import { queryAllByRole } from '@testing-library/react'; const darkTheme = createMuiTheme({ palette: { @@ -9,16 +11,88 @@ const darkTheme = createMuiTheme({ }, }); +export async function getArtists(filter: string) { + const query = filter.length > 0 ? { + prop: serverApi.QueryElemProperty.artistName, + propOperand: filter, + propOperator: serverApi.QueryFilterOp.Like, + } : {}; + + var q: serverApi.QueryRequest = { + query: query, + offsetsLimits: { + artistOffset: 0, + artistLimit: 100, + }, + 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(); + const names: string[] = json.artists.map((elem: any) => { return elem.name; }); + return [...new Set(names)]; + })(); +} + +export async function getSongTitles(filter: string) { + const query = filter.length > 0 ? { + prop: serverApi.QueryElemProperty.songTitle, + propOperand: filter, + propOperator: serverApi.QueryFilterOp.Like, + } : {}; + + var q: serverApi.QueryRequest = { + query: query, + offsetsLimits: { + songOffset: 0, + songLimit: 100, + }, + 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(); + const titles: string[] = json.songs.map((elem: any) => { return elem.title; }); + return [...new Set(titles)]; + })(); +} + export default function Window(props: any) { const [query, setQuery] = useState(null); - console.log("Query:", query); - return } \ No newline at end of file diff --git a/client/src/components/querybuilder/QBAddElemMenu.tsx b/client/src/components/querybuilder/QBAddElemMenu.tsx new file mode 100644 index 0000000..b0fc151 --- /dev/null +++ b/client/src/components/querybuilder/QBAddElemMenu.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Menu, MenuItem } from '@material-ui/core'; +import NestedMenuItem from "material-ui-nested-menu-item"; +import { QueryElem, QueryLeafBy, QueryLeafOp } from '../../lib/query/Query'; +import QBSelectWithRequest from './QBSelectWithRequest'; +import { Requests } from './QueryBuilder'; + +export interface MenuProps { + anchorEl: null | HTMLElement, + onClose: () => void, + onCreateQuery: (q: QueryElem) => void, + requestFunctions: Requests, +} + +export function QBAddElemMenu(props: MenuProps) { + let anchorEl = props.anchorEl; + let onClose = props.onClose; + + return + New query element + + { + onClose(); + props.onCreateQuery({ + a: QueryLeafBy.SongTitle, + leafOp: exact ? QueryLeafOp.Equals : QueryLeafOp.Like, + b: s + }); + }} + style={{ width: 300 }} + /> + + + { + onClose(); + props.onCreateQuery({ + a: QueryLeafBy.ArtistName, + leafOp: exact ? QueryLeafOp.Equals : QueryLeafOp.Like, + b: s + }); + }} + style={{ width: 300 }} + /> + + +} diff --git a/client/src/components/querybuilder/QBQueryButton.tsx b/client/src/components/querybuilder/QBEditButton.tsx similarity index 80% rename from client/src/components/querybuilder/QBQueryButton.tsx rename to client/src/components/querybuilder/QBEditButton.tsx index 91430eb..2692c29 100644 --- a/client/src/components/querybuilder/QBQueryButton.tsx +++ b/client/src/components/querybuilder/QBEditButton.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import { IconButton } from '@material-ui/core'; import SearchIcon from '@material-ui/icons/Search'; import CheckIcon from '@material-ui/icons/Check'; @@ -7,7 +7,7 @@ export interface IProps { editing: boolean } -export default function QBQueryButton(props: any) { +export default function QBEditButton(props: any) { return {(!props.editing) && } {(props.editing) && } diff --git a/client/src/components/querybuilder/QBQueryLeafElem.tsx b/client/src/components/querybuilder/QBLeafElem.tsx similarity index 92% rename from client/src/components/querybuilder/QBQueryLeafElem.tsx rename to client/src/components/querybuilder/QBLeafElem.tsx index 01f83cb..59a5a94 100644 --- a/client/src/components/querybuilder/QBQueryLeafElem.tsx +++ b/client/src/components/querybuilder/QBLeafElem.tsx @@ -1,8 +1,9 @@ import React from 'react'; import { QueryLeafElem, QueryLeafBy, QueryLeafOp, QueryElem } from '../../lib/query/Query'; import { Chip, Typography, IconButton, Box } from '@material-ui/core'; -import { QBQueryPlaceholder } from './QBQueryPlaceholder'; +import { QBPlaceholder } from './QBPlaceholder'; import CloseIcon from '@material-ui/icons/Close'; +import { Requests } from './QueryBuilder'; export interface ElemChipProps { label: any, @@ -64,9 +65,10 @@ export interface IProps { elem: QueryLeafElem, onReplace: (q: QueryElem | null) => void, editingQuery: boolean, + requestFunctions: Requests, } -export function QBQueryLeafElem(props: IProps) { +export function QBLeafElem(props: IProps) { let e = props.elem; const extraElements = props.editingQuery ? @@ -104,8 +106,9 @@ export function QBQueryLeafElem(props: IProps) { extraElements={extraElements} /> } else if (e.leafOp == QueryLeafOp.Placeholder) { - return } diff --git a/client/src/components/querybuilder/QBQueryNodeElem.tsx b/client/src/components/querybuilder/QBNodeElem.tsx similarity index 84% rename from client/src/components/querybuilder/QBQueryNodeElem.tsx rename to client/src/components/querybuilder/QBNodeElem.tsx index 1d0855b..5551390 100644 --- a/client/src/components/querybuilder/QBQueryNodeElem.tsx +++ b/client/src/components/querybuilder/QBNodeElem.tsx @@ -2,17 +2,19 @@ import React from 'react'; import QBOrBlock from './QBOrBlock'; import QBAndBlock from './QBAndBlock'; import { QueryNodeElem, QueryNodeOp, QueryElem, isNodeElem, simplify } from '../../lib/query/Query'; -import { QBQueryLeafElem } from './QBQueryLeafElem'; +import { QBLeafElem } from './QBLeafElem'; import { QBQueryElem } from './QBQueryElem'; import { O_APPEND } from 'constants'; +import { Requests } from './QueryBuilder'; export interface NodeProps { elem: QueryNodeElem, onReplace: (q: QueryElem | null) => void, editingQuery: boolean, + requestFunctions: Requests, } -export function QBQueryNodeElem(props: NodeProps) { +export function QBNodeElem(props: NodeProps) { let e = props.elem; const onReplace = (idx: number, q: QueryElem | null) => { @@ -31,6 +33,7 @@ export function QBQueryNodeElem(props: NodeProps) { elem={o} onReplace={(q: QueryElem | null) => onReplace(idx, q)} editingQuery={props.editingQuery} + requestFunctions={props.requestFunctions} /> }); diff --git a/client/src/components/querybuilder/QBQueryPlaceholder.tsx b/client/src/components/querybuilder/QBPlaceholder.tsx similarity index 76% rename from client/src/components/querybuilder/QBQueryPlaceholder.tsx rename to client/src/components/querybuilder/QBPlaceholder.tsx index 4497e41..00847c0 100644 --- a/client/src/components/querybuilder/QBQueryPlaceholder.tsx +++ b/client/src/components/querybuilder/QBPlaceholder.tsx @@ -1,13 +1,15 @@ import React from 'react'; import { Chip } from '@material-ui/core'; -import { QBQueryElemMenu } from './QBQueryElemMenu'; +import { QBAddElemMenu } from './QBAddElemMenu'; import { QueryElem } from '../../lib/query/Query'; +import { Requests } from './QueryBuilder'; export interface IProps { onReplace: (q: QueryElem) => void, + requestFunctions: Requests, } -export function QBQueryPlaceholder(props: IProps) { +export function QBPlaceholder(props: IProps & any) { const [anchorEl, setAnchorEl] = React.useState(null); const onOpen = (event: any) => { @@ -29,10 +31,11 @@ export function QBQueryPlaceholder(props: IProps) { onClick={onOpen} component="div" /> - } \ No newline at end of file diff --git a/client/src/components/querybuilder/QBQueryElem.tsx b/client/src/components/querybuilder/QBQueryElem.tsx index 1655ec8..8fafebf 100644 --- a/client/src/components/querybuilder/QBQueryElem.tsx +++ b/client/src/components/querybuilder/QBQueryElem.tsx @@ -1,30 +1,34 @@ import React from 'react'; import { QueryLeafElem, QueryNodeElem, QueryElem, isLeafElem, isNodeElem } from '../../lib/query/Query'; -import { QBQueryLeafElem } from './QBQueryLeafElem'; -import { QBQueryNodeElem } from './QBQueryNodeElem'; +import { QBLeafElem } from './QBLeafElem'; +import { QBNodeElem } from './QBNodeElem'; +import { Requests } from './QueryBuilder'; export interface IProps { elem: QueryLeafElem | QueryNodeElem, onReplace: (q: QueryElem | null) => void, editingQuery: boolean, + requestFunctions: Requests, } export function QBQueryElem(props: IProps) { let e = props.elem; if (isLeafElem(e)) { - return } else if (isNodeElem(e)) { - return } - throw "Unsupported query element"; + throw new Error("Unsupported query element"); } \ No newline at end of file diff --git a/client/src/components/querybuilder/QBQueryElemMenu.tsx b/client/src/components/querybuilder/QBQueryElemMenu.tsx deleted file mode 100644 index 75d2167..0000000 --- a/client/src/components/querybuilder/QBQueryElemMenu.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import React, { useState } from 'react'; -import { Menu, TextField } from '@material-ui/core'; -import NestedMenuItem from "material-ui-nested-menu-item"; -import { QueryElem, QueryLeafBy, QueryLeafOp } from '../../lib/query/Query'; -import Autocomplete from '@material-ui/lab/Autocomplete' - -export interface FreeSoloInputProps { - label: string, - options: string[], - onSubmit: (s: string, exactMatch: boolean) => void, -} - -export function QBMenuFreeSoloInput(props: FreeSoloInputProps) { - const [value, setValue] = useState(''); - - const onInputChange = (event: any, _val: any, reason: any) => { - if (reason === 'reset') { - // User selected a preset option. - props.onSubmit(_val, true); - } else { - setValue(_val); - } - } - - return { - if (e.key === 'Enter') { - // User submitted free-form value. - props.onSubmit(value, props.options.includes(value)); - } - }} - renderInput={(params: any) => } - /> -} - - -export interface MenuProps { - anchorEl: null | HTMLElement, - onClose: () => void, - onCreateQuery: (q: QueryElem) => void, -} - -export function QBQueryElemMenu(props: MenuProps) { - let anchorEl = props.anchorEl; - let onClose = props.onClose; - - const artists = [ - 'Queens', - 'Muse', - 'Triggerfinger' - ]; - - const songs = [ - 'Drop It Like Its Hot', - 'Bla', - 'Stuff' - ]; - - return - - { - onClose(); - props.onCreateQuery({ - a: QueryLeafBy.SongTitle, - leafOp: exact ? QueryLeafOp.Equals : QueryLeafOp.Like, - b: s - }); - }} - /> - - - { - onClose(); - props.onCreateQuery({ - a: QueryLeafBy.ArtistName, - leafOp: exact ? QueryLeafOp.Equals : QueryLeafOp.Like, - b: s - }); - }} - /> - - -} diff --git a/client/src/components/querybuilder/QBSelectWithRequest.tsx b/client/src/components/querybuilder/QBSelectWithRequest.tsx new file mode 100644 index 0000000..b4caac1 --- /dev/null +++ b/client/src/components/querybuilder/QBSelectWithRequest.tsx @@ -0,0 +1,121 @@ +import React, { useState, useEffect } from 'react'; +import TextField from '@material-ui/core/TextField'; +import Autocomplete from '@material-ui/lab/Autocomplete'; +import CircularProgress from '@material-ui/core/CircularProgress'; + +interface IProps { + getNewOptions: (textInput: string) => Promise, + label: string, + onSubmit: (s: string, exactMatch: boolean) => void, +} + +// Autocompleted combo box which can make asynchronous requests +// to get new options. +// Based on Material UI example: https://material-ui.com/components/autocomplete/ +export default function QBSelectWithRequest(props: IProps & any) { + interface OptionsFor { + forInput: string, + options: string[], + }; + + const [open, setOpen] = useState(false); + const [options, setOptions] = useState(null); + const [input, setInput] = useState(""); + + const { getNewOptions, label, onSubmit, ...restProps } = props; + + const loading: boolean = !options || options.forInput !== input; + + const updateOptions = (forInput: string, options: any[]) => { + if (forInput === input) { + console.log("setting options."); + setOptions({ + forInput: forInput, + options: options, + }); + } + } + + const startRequest = (_input: string) => { + console.log('starting req', _input); + setInput(_input); + (async () => { + const newOptions = await getNewOptions(_input); + console.log('new options', newOptions); + updateOptions(_input, newOptions); + })(); + }; + + // // Ensure a new request is made whenever the loading option is enabled. + // useEffect(() => { + // startRequest(input); + // }, []); + + // Ensure options are cleared whenever the element is closed. + // useEffect(() => { + // if (!open) { + // setOptions(null); + // } + // }, [open]); + + useEffect(() => { + startRequest(input); + }, [input]); + + const onInputChange = (e: any, val: any, reason: any) => { + if (reason === 'reset') { + // User selected a preset option. + props.onSubmit(val, true); + } else { + // User changed text, start a new request. + setInput(val); + } + } + + console.log("Render props:", props); + + return ( + { + setOpen(true); + }} + onClose={() => { + setOpen(false); + }} + getOptionSelected={(option, value) => option === value} + getOptionLabel={(option) => option} + options={options ? options.options : null} + loading={loading} + freeSolo={true} + value={input} + onInputChange={onInputChange} + onKeyDown={(e: any) => { + // Prevent the event from propagating, because + // that would trigger keyboard navigation of the menu. + e.stopPropagation(); + if (e.key === 'Enter') { + // User submitted free-form value. + props.onSubmit(input, options && options.options.includes(input)); + } + }} + renderInput={(params) => ( + + {loading ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + /> + ); +} diff --git a/client/src/components/querybuilder/QueryBuilder.tsx b/client/src/components/querybuilder/QueryBuilder.tsx index f14fb9e..4c0ad2f 100644 --- a/client/src/components/querybuilder/QueryBuilder.tsx +++ b/client/src/components/querybuilder/QueryBuilder.tsx @@ -1,12 +1,19 @@ import React, { useState } from 'react'; import { Box } from '@material-ui/core'; -import QBQueryButton from './QBQueryButton'; +import QBQueryButton from './QBEditButton'; import { QBQueryElem } from './QBQueryElem'; import { QueryElem, addPlaceholders, removePlaceholders, simplify } from '../../lib/query/Query'; + +export interface Requests { + getArtists: (filter: string) => Promise, + getSongTitles: (filter: string) => Promise, +} + export interface IProps { query: QueryElem | null, onChangeQuery: (q: QueryElem | null) => void, + requestFunctions: Requests, } export default function QueryBuilder(props: IProps) { @@ -34,6 +41,7 @@ export default function QueryBuilder(props: IProps) { elem={showQuery} onReplace={onReplace} editingQuery={editing} + requestFunctions={props.requestFunctions} />} diff --git a/client/src/lib/query/Query.tsx b/client/src/lib/query/Query.tsx index 0171e63..09197ca 100644 --- a/client/src/lib/query/Query.tsx +++ b/client/src/lib/query/Query.tsx @@ -1,5 +1,3 @@ -import { inflateRawSync } from "zlib"; - export enum QueryLeafBy { ArtistName = 0, AlbumName, diff --git a/client/tsconfig.json b/client/tsconfig.json index f2850b7..2254446 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es6", "lib": [ "dom", "dom.iterable", diff --git a/server/endpoints/QueryEndpointHandler.ts b/server/endpoints/QueryEndpointHandler.ts index 4707b32..7eedd3f 100644 --- a/server/endpoints/QueryEndpointHandler.ts +++ b/server/endpoints/QueryEndpointHandler.ts @@ -104,7 +104,9 @@ function addLeafWhere(knexQuery: any, queryElem: api.QueryElem, type: WhereType) if (!queryElem.propOperator) throw "Cannot create where clause without an operator."; const operator = queryElem.propOperator || api.QueryFilterOp.Eq; const a = queryElem.prop && propertyKeys[queryElem.prop]; - const b = queryElem.propOperand || ""; + const b = operator === api.QueryFilterOp.Like ? + '%' + (queryElem.propOperand || "") + '%' + : (queryElem.propOperand || ""); if (Object.keys(simpleLeafOps).includes(operator)) { if (type == WhereType.And) {