Interactive queries seem to work!

pull/16/head
Sander Vocke 5 years ago
parent a36830802f
commit 9c9d1b6dd8
  1. 4
      client/src/components/Mockup.tsx
  2. 78
      client/src/components/Window.tsx
  3. 63
      client/src/components/querybuilder/QBAddElemMenu.tsx
  4. 4
      client/src/components/querybuilder/QBEditButton.tsx
  5. 9
      client/src/components/querybuilder/QBLeafElem.tsx
  6. 7
      client/src/components/querybuilder/QBNodeElem.tsx
  7. 9
      client/src/components/querybuilder/QBPlaceholder.tsx
  8. 14
      client/src/components/querybuilder/QBQueryElem.tsx
  9. 108
      client/src/components/querybuilder/QBQueryElemMenu.tsx
  10. 121
      client/src/components/querybuilder/QBSelectWithRequest.tsx
  11. 10
      client/src/components/querybuilder/QueryBuilder.tsx
  12. 2
      client/src/lib/query/Query.tsx
  13. 2
      client/tsconfig.json
  14. 4
      server/endpoints/QueryEndpointHandler.ts

@ -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 <>
<AppBar position="static" style={{background: 'grey'}}>
<Box m={0.5} display="flex" alignItems="center">
<img height="30px" src={process.env.PUBLIC_URL + "/logo.svg"}></img>
<img height="30px" src={process.env.PUBLIC_URL + "/logo.svg"} alt="error"></img>
</Box>
</AppBar>
<Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">

@ -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<QueryElem | null>(null);
console.log("Query:", query);
return <ThemeProvider theme={darkTheme}>
<CssBaseline />
<QueryBuilder
query={query}
onChangeQuery={setQuery}
requestFunctions={{
getArtists: getArtists,
getSongTitles: getSongTitles,
}}
/>
</ThemeProvider>
}

@ -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 <Menu
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={onClose}
>
<MenuItem disabled={true}>New query element</MenuItem>
<NestedMenuItem
label="Song"
parentMenuOpen={Boolean(anchorEl)}
>
<QBSelectWithRequest
label="Title"
getNewOptions={props.requestFunctions.getSongTitles}
onSubmit={(s: string, exact: boolean) => {
onClose();
props.onCreateQuery({
a: QueryLeafBy.SongTitle,
leafOp: exact ? QueryLeafOp.Equals : QueryLeafOp.Like,
b: s
});
}}
style={{ width: 300 }}
/>
</NestedMenuItem>
<NestedMenuItem
label="Artist"
parentMenuOpen={Boolean(anchorEl)}
>
<QBSelectWithRequest
label="Name"
getNewOptions={props.requestFunctions.getArtists}
onSubmit={(s: string, exact: boolean) => {
onClose();
props.onCreateQuery({
a: QueryLeafBy.ArtistName,
leafOp: exact ? QueryLeafOp.Equals : QueryLeafOp.Like,
b: s
});
}}
style={{ width: 300 }}
/>
</NestedMenuItem>
</Menu >
}

@ -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 <IconButton {...props}>
{(!props.editing) && <SearchIcon style={{ fontSize: 80 }} />}
{(props.editing) && <CheckIcon style={{ fontSize: 80 }} />}

@ -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 <QBQueryPlaceholder
return <QBPlaceholder
onReplace={props.onReplace}
requestFunctions={props.requestFunctions}
/>
}

@ -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}
/>
});

@ -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 | HTMLElement>(null);
const onOpen = (event: any) => {
@ -29,10 +31,11 @@ export function QBQueryPlaceholder(props: IProps) {
onClick={onOpen}
component="div"
/>
<QBQueryElemMenu
<QBAddElemMenu
anchorEl={anchorEl}
onClose={onClose}
onCreateQuery={onCreate}
requestFunctions={props.requestFunctions}
/>
</>
}

@ -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 <QBQueryLeafElem
return <QBLeafElem
elem={e}
onReplace={props.onReplace}
editingQuery={props.editingQuery}
requestFunctions={props.requestFunctions}
/>
} else if (isNodeElem(e)) {
return <QBQueryNodeElem
return <QBNodeElem
elem={e}
onReplace={props.onReplace}
editingQuery={props.editingQuery}
requestFunctions={props.requestFunctions}
/>
}
throw "Unsupported query element";
throw new Error("Unsupported query element");
}

@ -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<string>('');
const onInputChange = (event: any, _val: any, reason: any) => {
if (reason === 'reset') {
// User selected a preset option.
props.onSubmit(_val, true);
} else {
setValue(_val);
}
}
return <Autocomplete
style={{ width: 300 }}
options={props.options}
freeSolo={true}
onInputChange={onInputChange}
onKeyDown={(e: any) => {
if (e.key === 'Enter') {
// User submitted free-form value.
props.onSubmit(value, props.options.includes(value));
}
}}
renderInput={(params: any) => <TextField
{...params}
label={props.label}
/>}
/>
}
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 <Menu
id="simple-menu"
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={onClose}
>
<NestedMenuItem
label="Song"
parentMenuOpen={Boolean(anchorEl)}
>
<QBMenuFreeSoloInput
options={songs}
label="Title"
onSubmit={(s: string, exact: boolean) => {
onClose();
props.onCreateQuery({
a: QueryLeafBy.SongTitle,
leafOp: exact ? QueryLeafOp.Equals : QueryLeafOp.Like,
b: s
});
}}
/>
</NestedMenuItem>
<NestedMenuItem
label="Artist"
parentMenuOpen={Boolean(anchorEl)}
>
<QBMenuFreeSoloInput
options={artists}
label="Artist"
onSubmit={(s: string, exact: boolean) => {
onClose();
props.onCreateQuery({
a: QueryLeafBy.ArtistName,
leafOp: exact ? QueryLeafOp.Equals : QueryLeafOp.Like,
b: s
});
}}
/>
</NestedMenuItem>
</Menu >
}

@ -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<string[]>,
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<OptionsFor | null>(null);
const [input, setInput] = useState<string>("");
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 (
<Autocomplete
{...restProps}
open={open}
onOpen={() => {
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) => (
<TextField
{...params}
label={label}
variant="outlined"
InputProps={{
...params.InputProps,
endAdornment: (
<React.Fragment>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</React.Fragment>
),
}}
/>
)}
/>
);
}

@ -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<string[]>,
getSongTitles: (filter: string) => Promise<string[]>,
}
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}
/>}
</Box>
</Box>

@ -1,5 +1,3 @@
import { inflateRawSync } from "zlib";
export enum QueryLeafBy {
ArtistName = 0,
AlbumName,

@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "es6",
"lib": [
"dom",
"dom.iterable",

@ -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) {

Loading…
Cancel
Save