Almost done with basic query manipulation.

pull/16/head
Sander Vocke 5 years ago
parent c197e23c04
commit e3d741d466
  1. 27
      client/src/components/Window.tsx
  2. 4
      client/src/components/querybuilder/QBAndBlock.tsx
  3. 15
      client/src/components/querybuilder/QBOrBlock.tsx
  4. 123
      client/src/components/querybuilder/QBQueryButton.tsx
  5. 19
      client/src/components/querybuilder/QBQueryElem.tsx
  6. 108
      client/src/components/querybuilder/QBQueryElemMenu.tsx
  7. 10
      client/src/components/querybuilder/QBQueryLeafElem.tsx
  8. 47
      client/src/components/querybuilder/QBQueryNodeElem.tsx
  9. 38
      client/src/components/querybuilder/QBQueryPlaceholder.tsx
  10. 40
      client/src/components/querybuilder/QueryBuilder.tsx
  11. 142
      client/src/lib/Query.tsx

@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import { ThemeProvider, CssBaseline, createMuiTheme } from '@material-ui/core'; import { ThemeProvider, CssBaseline, createMuiTheme } from '@material-ui/core';
import { QueryLeafElem, QueryLeafBy, QueryLeafOp, QueryElem, QueryNodeElem, queryOr, queryAnd } from '../lib/Query'; import { QueryLeafElem, QueryLeafBy, QueryLeafOp, QueryElem, QueryNodeElem, queryOr, queryAnd } from '../lib/Query';
import QueryBuilder from './querybuilder/QueryBuilder'; import QueryBuilder from './querybuilder/QueryBuilder';
@ -10,28 +10,13 @@ const darkTheme = createMuiTheme({
}); });
export default function Window(props: any) { export default function Window(props: any) {
let queens = new QueryLeafElem( const [query, setQuery] = useState<QueryElem | null>(null);
QueryLeafBy.ArtistName,
QueryLeafOp.Equals,
"Queens of the Stone Age"
);
let muse = new QueryLeafElem(
QueryLeafBy.ArtistName,
QueryLeafOp.Equals,
"Muse"
);
let dawnbros = new QueryLeafElem(
QueryLeafBy.ArtistName,
QueryLeafOp.Equals,
"Dawn Brothers"
);
let query = queryOr(
queryAnd(queens, muse),
dawnbros
);
return <ThemeProvider theme={darkTheme}> return <ThemeProvider theme={darkTheme}>
<CssBaseline /> <CssBaseline />
<QueryBuilder query={query} /> <QueryBuilder
query={query}
onChangeQuery={setQuery}
/>
</ThemeProvider> </ThemeProvider>
} }

@ -5,8 +5,8 @@ export default function QBAndBlock(props: any) {
return <Paper elevation={3}> return <Paper elevation={3}>
<Box display="flex" flexDirection="column" alignItems="center"> <Box display="flex" flexDirection="column" alignItems="center">
<Box m={0.5} /> <Box m={0.5} />
{props.children.map((child: any) => { {props.children.map((child: any, idx: number) => {
return <Box m={0.5}> return <Box m={0.5} key={idx}>
{child} {child}
</Box> </Box>
})} })}

@ -1,6 +1,10 @@
import React from 'react'; import React from 'react';
import { Box, Typography } from '@material-ui/core'; import { Box, Typography } from '@material-ui/core';
export interface IProps {
children: any,
}
export default function QBOrBlock(props: any) { export default function QBOrBlock(props: any) {
const firstChild = Array.isArray(props.children) && props.children.length >= 1 ? const firstChild = Array.isArray(props.children) && props.children.length >= 1 ?
props.children[0] : undefined; props.children[0] : undefined;
@ -8,19 +12,22 @@ export default function QBOrBlock(props: any) {
const otherChildren = Array.isArray(props.children) && props.children.length > 1 ? const otherChildren = Array.isArray(props.children) && props.children.length > 1 ?
props.children.slice(1) : []; props.children.slice(1) : [];
return <Box display="flex" alignItems="center"> return <Box
display="flex"
alignItems="center"
>
<Box m={1}> <Box m={1}>
{firstChild} {firstChild}
</Box> </Box>
{otherChildren.map((child: any) => { {otherChildren.map((child: any, idx: number) => {
return <> return <Box display="flex" alignItems="center" key={idx}>
<Box m={1}> <Box m={1}>
<Typography variant="button">Or</Typography> <Typography variant="button">Or</Typography>
</Box> </Box>
<Box m={1}> <Box m={1}>
{child} {child}
</Box> </Box>
</>; </Box>;
})} })}
</Box> </Box>
} }

@ -1,122 +1,15 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { IconButton, Menu, MenuItem, TextField, Box } from '@material-ui/core'; import { IconButton } from '@material-ui/core';
import SearchIcon from '@material-ui/icons/Search'; import SearchIcon from '@material-ui/icons/Search';
import NestedMenuItem from "material-ui-nested-menu-item"; import CheckIcon from '@material-ui/icons/Check';
import { QueryElem, QueryLeafBy, QueryLeafOp } from '../../lib/Query';
import Autocomplete from '@material-ui/lab/Autocomplete'
export function QBQueryButtonBase(props: any) {
return <IconButton {...props}>
<SearchIcon style={{ fontSize: 80 }} />
</IconButton>
}
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 QBQueryButtonMenu(props: MenuProps) {
let anchorEl = props.anchorEl;
let onClose = props.onClose;
const artists = [
'Queens',
'Muse',
'Triggerfinger'
];
return <Menu
id="simple-menu"
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={onClose}
>
<NestedMenuItem
label="Artist"
parentMenuOpen={Boolean(anchorEl)}
>
<QBMenuFreeSoloInput
options={artists}
label="Artist"
onSubmit={(s: string, exact: boolean) => {
onClose();
props.onCreateQuery({
a: QueryLeafBy.ArtistName,
op: exact ? QueryLeafOp.Equals : QueryLeafOp.Like,
b: s
});
}}
/>
</NestedMenuItem>
</Menu >
}
export interface IProps { export interface IProps {
editing: boolean
} }
export default function QBQueryButton(props: IProps) { export default function QBQueryButton(props: any) {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null); return <IconButton {...props}>
const onOpen = (e: React.MouseEvent<HTMLButtonElement>) => { {(!props.editing) && <SearchIcon style={{ fontSize: 80 }} />}
setAnchorEl(e.currentTarget); {(props.editing) && <CheckIcon style={{ fontSize: 80 }} />}
} </IconButton>
const onClose = () => {
setAnchorEl(null);
}
const onCreateQuery = (q: QueryElem) => {
console.log("created query:", q);
}
return <>
<QBQueryButtonBase
aria-controls="simple-menu"
aria-haspopup="true"
onClick={onOpen}
/>
<QBQueryButtonMenu
anchorEl={anchorEl}
onClose={onClose}
onCreateQuery={onCreateQuery}
/>
</>;
} }

@ -1,24 +1,23 @@
import React from 'react'; import React from 'react';
import { QueryLeafElem, QueryNodeElem } from '../../lib/Query'; import { QueryLeafElem, QueryNodeElem, QueryElem, isLeafElem, isNodeElem } from '../../lib/Query';
import { QBQueryLeafElem } from './QBQueryLeafElem'; import { QBQueryLeafElem } from './QBQueryLeafElem';
import { QBQueryNodeElem } from './QBQueryNodeElem'; import { QBQueryNodeElem } from './QBQueryNodeElem';
export interface IProps { export interface IProps {
elem: QueryLeafElem | QueryNodeElem, elem: QueryLeafElem | QueryNodeElem,
onReplace: (q: QueryElem) => void,
} }
export function QBQueryElem(props: IProps) { export function QBQueryElem(props: IProps) {
let e = props.elem; let e = props.elem;
let renderLeaf = (l: any) => { if (isLeafElem(e)) {
return <QBQueryLeafElem elem={l} /> return <QBQueryLeafElem elem={e} onReplace={props.onReplace} />
} } else if (isNodeElem(e)) {
return <QBQueryNodeElem
if (e instanceof QueryLeafElem) { elem={e}
return renderLeaf(e); onReplace={props.onReplace}
} else if (e instanceof QueryNodeElem) { />
return <QBQueryNodeElem elem={e}
renderLeaf={renderLeaf} />
} }
throw "Unsupported query element"; throw "Unsupported query element";

@ -0,0 +1,108 @@
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';
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 >
}

@ -1,9 +1,11 @@
import React from 'react'; import React from 'react';
import { QueryLeafElem, QueryLeafBy, QueryLeafOp, QueryNodeElem, QueryNodeOp } from '../../lib/Query'; import { QueryLeafElem, QueryLeafBy, QueryLeafOp, QueryElem } from '../../lib/Query';
import { Chip, Typography } from '@material-ui/core'; import { Chip, Typography } from '@material-ui/core';
import { QBQueryPlaceholder } from './QBQueryPlaceholder';
export interface LeafProps { export interface LeafProps {
elem: QueryLeafElem elem: QueryLeafElem
onReplace: (q: QueryElem) => void
} }
export function QBQueryElemArtistEquals(props: LeafProps) { export function QBQueryElemArtistEquals(props: LeafProps) {
@ -20,9 +22,13 @@ export function QBQueryLeafElem(props: LeafProps) {
let e = props.elem; let e = props.elem;
if (e.a == QueryLeafBy.ArtistName && if (e.a == QueryLeafBy.ArtistName &&
e.op == QueryLeafOp.Equals && e.leafOp == QueryLeafOp.Equals &&
typeof e.b == "string") { typeof e.b == "string") {
return <QBQueryElemArtistEquals {...props} /> return <QBQueryElemArtistEquals {...props} />
} else if (e.leafOp == QueryLeafOp.Placeholder) {
return <QBQueryPlaceholder
onReplace={props.onReplace}
/>
} }
throw "Unsupported leaf element"; throw "Unsupported leaf element";

@ -1,34 +1,41 @@
import React from 'react'; import React from 'react';
import QBOrBlock from './QBOrBlock'; import QBOrBlock from './QBOrBlock';
import QBAndBlock from './QBAndBlock'; import QBAndBlock from './QBAndBlock';
import { QueryNodeElem, QueryNodeOp } from '../../lib/Query'; import { QueryNodeElem, QueryNodeOp, QueryElem, isNodeElem } from '../../lib/Query';
import { QBQueryLeafElem } from './QBQueryLeafElem';
export interface NodeProps { export interface NodeProps {
elem: QueryNodeElem, elem: QueryNodeElem,
renderLeaf: (leaf: any) => any, onReplace: (q: QueryElem) => void,
} }
export function QBQueryNodeElem(props: NodeProps) { export function QBQueryNodeElem(props: NodeProps) {
let e = props.elem; let e = props.elem;
if (e.op == QueryNodeOp.And) { const onReplace = (idx: number, q: QueryElem) => {
return <QBAndBlock> var ops = e.operands;
{e.operands.map((o: any) => { ops[idx] = q;
if(o instanceof QueryNodeElem) { let newNode = { operands: ops, nodeOp: e.nodeOp };
return <QBQueryNodeElem elem={o} renderLeaf={props.renderLeaf}/> props.onReplace(newNode);
} }
return props.renderLeaf(o);
})} const children = e.operands.map((o: any, idx: number) => {
</QBAndBlock> if (isNodeElem(o)) {
} else if (e.op == QueryNodeOp.Or) { return <QBQueryNodeElem
return <QBOrBlock> elem={o}
{e.operands.map((o: any) => { onReplace={(q: QueryElem) => onReplace(idx, q)}
if(o instanceof QueryNodeElem) { />
return <QBQueryNodeElem elem={o} renderLeaf={props.renderLeaf}/> }
} return <QBQueryLeafElem
return props.renderLeaf(o); elem={o}
})} onReplace={(q: QueryElem) => onReplace(idx, q)}
</QBOrBlock> />
});
if (e.nodeOp == QueryNodeOp.And) {
return <QBAndBlock>{children}</QBAndBlock>
} else if (e.nodeOp == QueryNodeOp.Or) {
return <QBOrBlock>{children}</QBOrBlock>
} }
throw "Unsupported node element"; throw "Unsupported node element";

@ -0,0 +1,38 @@
import React from 'react';
import { Chip } from '@material-ui/core';
import { QBQueryElemMenu } from './QBQueryElemMenu';
import { QueryElem } from '../../lib/Query';
export interface IProps {
onReplace: (q: QueryElem) => void,
}
export function QBQueryPlaceholder(props: IProps) {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const onOpen = (event: any) => {
setAnchorEl(event.currentTarget);
};
const onClose = () => {
setAnchorEl(null);
};
const onCreate = (q: QueryElem) => {
props.onReplace(q);
};
return <>
<Chip
variant="outlined"
label=""
style={{ width: "50px" }}
clickable={true}
onClick={onOpen}
component="div"
/>
<QBQueryElemMenu
anchorEl={anchorEl}
onClose={onClose}
onCreateQuery={onCreate}
/>
</>
}

@ -1,20 +1,40 @@
import React from 'react'; import React, { useState } from 'react';
import { Box } from '@material-ui/core'; import { Box } from '@material-ui/core';
import QBQueryButton from './QBQueryButton'; import QBQueryButton from './QBQueryButton';
import { QBQueryElem } from './QBQueryElem'; import { QBQueryElem } from './QBQueryElem';
import { QueryElem } from '../../lib/Query'; import { QueryElem, addPlaceholders, removePlaceholders, simplify } from '../../lib/Query';
export interface IProps { export interface IProps {
query: QueryElem query: QueryElem | null,
onChangeQuery: (q: QueryElem | null) => void,
} }
export default function QueryBuilder(props: IProps) { export default function QueryBuilder(props: IProps) {
return <Box display="flex" alignItems="center"> const [editing, setEditing] = useState<boolean>(false);
<Box m={2}>
<QBQueryButton/> const simpleQuery = simplify(props.query);
</Box> const showQuery = editing ?
<Box m={2}> addPlaceholders(simpleQuery, null) : simpleQuery;
<QBQueryElem elem={props.query}/>
const onReplace = (q: any) => {
const newQ = removePlaceholders(q);
props.onChangeQuery(newQ);
}
return <>
<Box display="flex" alignItems="center">
<Box m={2}>
<QBQueryButton
onClick={() => setEditing(!editing)}
editing={editing}
/>
</Box>
<Box m={2}>
{showQuery && <QBQueryElem
elem={showQuery}
onReplace={onReplace}
/>}
</Box>
</Box> </Box>
</Box> </>
} }

@ -1,3 +1,5 @@
import { inflateRawSync } from "zlib";
export enum QueryLeafBy { export enum QueryLeafBy {
ArtistName = 0, ArtistName = 0,
AlbumName, AlbumName,
@ -8,56 +10,136 @@ export enum QueryLeafBy {
export enum QueryLeafOp { export enum QueryLeafOp {
Equals = 0, Equals = 0,
Like, Like,
Placeholder, // Special op which indicates that this leaf is not filled in yet.
} }
export type QueryLeafOperand = string | number; export type QueryLeafOperand = string | number;
export class QueryLeafElem { export interface QueryLeafElem {
a: QueryLeafBy; a: QueryLeafBy;
op: QueryLeafOp; leafOp: QueryLeafOp;
b: QueryLeafOperand; b: QueryLeafOperand;
constructor(
a: QueryLeafBy,
op: QueryLeafOp,
b: QueryLeafOperand
) {
this.a = a;
this.op = op;
this.b = b;
}
}; };
export function isLeafElem(q: QueryElem): q is QueryLeafElem {
return 'leafOp' in q;
}
export enum QueryNodeOp { export enum QueryNodeOp {
And = 0, And = 0,
Or, Or,
} }
export class QueryNodeElem { export interface QueryNodeElem {
operands: QueryElem[]; operands: QueryElem[];
op: QueryNodeOp; nodeOp: QueryNodeOp;
}
constructor( export function isNodeElem(q: QueryElem): q is QueryNodeElem {
operands: QueryElem[], return 'nodeOp' in q;
op: QueryNodeOp
) {
this.operands = operands;
this.op = op;
}
} }
export function queryOr(...args: QueryElem[]) { export function queryOr(...args: QueryElem[]) {
return new QueryNodeElem( return {
args, operands: args,
QueryNodeOp.Or nodeOp: QueryNodeOp.Or
); }
} }
export function queryAnd(...args: QueryElem[]) { export function queryAnd(...args: QueryElem[]) {
return new QueryNodeElem( return {
args, operands: args,
QueryNodeOp.And nodeOp: QueryNodeOp.And
); };
} }
export type QueryElem = QueryLeafElem | QueryNodeElem; export type QueryElem = QueryLeafElem | QueryNodeElem;
// Take a query and add placeholders. The placeholders are empty
// leaves. They should be placed so that all possible node combinations
// from the existing nodes could have an added combinational leaf.
// In other words: for AND/OR, this should result in a query that has
// placeholders for all AND/OR combinations with existing nodes.
export function addPlaceholders(
q: QueryElem | null,
inNode: null | QueryNodeOp.And | QueryNodeOp.Or,
): QueryElem {
const makePlaceholder = () => {
return {
a: 0,
leafOp: QueryLeafOp.Placeholder,
b: ""
}
};
const otherOp: Record<QueryNodeOp, QueryNodeOp> = {
[QueryNodeOp.And]: QueryNodeOp.Or,
[QueryNodeOp.Or]: QueryNodeOp.And,
}
if (q == null) {
return makePlaceholder();
} else if (isNodeElem(q)) {
var operands = q.operands.map((op: any, idx: number) => {
return addPlaceholders(op, q.nodeOp);
});
operands.push(makePlaceholder());
const newBlock = { operands: operands, nodeOp: q.nodeOp };
if (inNode == null) {
return { operands: [newBlock, makePlaceholder()], nodeOp: otherOp[q.nodeOp] };
} else {
return newBlock;
}
} else if (isLeafElem(q) &&
q.leafOp != QueryLeafOp.Placeholder &&
inNode !== null) {
return { operands: [q, makePlaceholder()], nodeOp: otherOp[inNode] };
}
return q;
}
// See addPlaceholders.
export function removePlaceholders(q: QueryElem | null): QueryElem | null {
if (q && isNodeElem(q)) {
var newOperands: QueryElem[] = [];
q.operands.forEach((op: any) => {
if (isLeafElem(op) && op.leafOp == QueryLeafOp.Placeholder) {
return;
}
const newOp = removePlaceholders(op);
if (newOp) {
newOperands.push(newOp);
}
})
if (newOperands.length == 0) {
return null;
}
if (newOperands.length == 1) {
return newOperands[0];
}
return { operands: newOperands, nodeOp: q.nodeOp };
} else if (q && isLeafElem(q) && q.leafOp == QueryLeafOp.Placeholder) {
return null;
}
return q;
}
export function simplify(q: QueryElem | null): QueryElem | null {
if (q && isNodeElem(q)) {
var newOperands: QueryElem[] = [];
q.operands.forEach((o: QueryElem) => {
const s = simplify(o);
if (s !== null) { newOperands.push(s); }
})
if (newOperands.length === 0) { return null; }
if (newOperands.length === 1) { return newOperands[0]; }
return { operands: newOperands, nodeOp: q.nodeOp };
}
return q;
}
Loading…
Cancel
Save