From e3d741d46638a9c21baa0ca5fd1251352febf046 Mon Sep 17 00:00:00 2001 From: Sander Vocke Date: Fri, 11 Sep 2020 17:37:46 +0200 Subject: [PATCH] Almost done with basic query manipulation. --- client/src/components/Window.tsx | 27 +--- .../components/querybuilder/QBAndBlock.tsx | 4 +- .../src/components/querybuilder/QBOrBlock.tsx | 15 +- .../components/querybuilder/QBQueryButton.tsx | 123 +-------------- .../components/querybuilder/QBQueryElem.tsx | 19 ++- .../querybuilder/QBQueryElemMenu.tsx | 108 +++++++++++++ .../querybuilder/QBQueryLeafElem.tsx | 10 +- .../querybuilder/QBQueryNodeElem.tsx | 47 +++--- .../querybuilder/QBQueryPlaceholder.tsx | 38 +++++ .../components/querybuilder/QueryBuilder.tsx | 40 +++-- client/src/lib/Query.tsx | 144 ++++++++++++++---- 11 files changed, 360 insertions(+), 215 deletions(-) create mode 100644 client/src/components/querybuilder/QBQueryElemMenu.tsx create mode 100644 client/src/components/querybuilder/QBQueryPlaceholder.tsx diff --git a/client/src/components/Window.tsx b/client/src/components/Window.tsx index 31842be..49b0bc7 100644 --- a/client/src/components/Window.tsx +++ b/client/src/components/Window.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { ThemeProvider, CssBaseline, createMuiTheme } from '@material-ui/core'; import { QueryLeafElem, QueryLeafBy, QueryLeafOp, QueryElem, QueryNodeElem, queryOr, queryAnd } from '../lib/Query'; import QueryBuilder from './querybuilder/QueryBuilder'; @@ -10,28 +10,13 @@ const darkTheme = createMuiTheme({ }); export default function Window(props: any) { - let queens = new QueryLeafElem( - 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 - ); + const [query, setQuery] = useState(null); return - + } \ No newline at end of file diff --git a/client/src/components/querybuilder/QBAndBlock.tsx b/client/src/components/querybuilder/QBAndBlock.tsx index 402a326..398517d 100644 --- a/client/src/components/querybuilder/QBAndBlock.tsx +++ b/client/src/components/querybuilder/QBAndBlock.tsx @@ -5,8 +5,8 @@ export default function QBAndBlock(props: any) { return - {props.children.map((child: any) => { - return + {props.children.map((child: any, idx: number) => { + return {child} })} diff --git a/client/src/components/querybuilder/QBOrBlock.tsx b/client/src/components/querybuilder/QBOrBlock.tsx index deb09a9..e25b575 100644 --- a/client/src/components/querybuilder/QBOrBlock.tsx +++ b/client/src/components/querybuilder/QBOrBlock.tsx @@ -1,6 +1,10 @@ import React from 'react'; import { Box, Typography } from '@material-ui/core'; +export interface IProps { + children: any, +} + export default function QBOrBlock(props: any) { const firstChild = Array.isArray(props.children) && props.children.length >= 1 ? props.children[0] : undefined; @@ -8,19 +12,22 @@ export default function QBOrBlock(props: any) { const otherChildren = Array.isArray(props.children) && props.children.length > 1 ? props.children.slice(1) : []; - return + return {firstChild} - {otherChildren.map((child: any) => { - return <> + {otherChildren.map((child: any, idx: number) => { + return Or {child} - ; + ; })} } \ No newline at end of file diff --git a/client/src/components/querybuilder/QBQueryButton.tsx b/client/src/components/querybuilder/QBQueryButton.tsx index e1a1246..91430eb 100644 --- a/client/src/components/querybuilder/QBQueryButton.tsx +++ b/client/src/components/querybuilder/QBQueryButton.tsx @@ -1,122 +1,15 @@ 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 NestedMenuItem from "material-ui-nested-menu-item"; -import { QueryElem, QueryLeafBy, QueryLeafOp } from '../../lib/Query'; -import Autocomplete from '@material-ui/lab/Autocomplete' - -export function QBQueryButtonBase(props: any) { - return - - -} - -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 QBQueryButtonMenu(props: MenuProps) { - let anchorEl = props.anchorEl; - let onClose = props.onClose; - - const artists = [ - 'Queens', - 'Muse', - 'Triggerfinger' - ]; - - return - - { - onClose(); - props.onCreateQuery({ - a: QueryLeafBy.ArtistName, - op: exact ? QueryLeafOp.Equals : QueryLeafOp.Like, - b: s - }); - }} - /> - - -} +import CheckIcon from '@material-ui/icons/Check'; export interface IProps { - + editing: boolean } -export default function QBQueryButton(props: IProps) { - const [anchorEl, setAnchorEl] = React.useState(null); - const onOpen = (e: React.MouseEvent) => { - setAnchorEl(e.currentTarget); - } - const onClose = () => { - setAnchorEl(null); - } - const onCreateQuery = (q: QueryElem) => { - console.log("created query:", q); - } - - return <> - - - ; +export default function QBQueryButton(props: any) { + return + {(!props.editing) && } + {(props.editing) && } + } \ No newline at end of file diff --git a/client/src/components/querybuilder/QBQueryElem.tsx b/client/src/components/querybuilder/QBQueryElem.tsx index 1e71706..2478610 100644 --- a/client/src/components/querybuilder/QBQueryElem.tsx +++ b/client/src/components/querybuilder/QBQueryElem.tsx @@ -1,24 +1,23 @@ import React from 'react'; -import { QueryLeafElem, QueryNodeElem } from '../../lib/Query'; +import { QueryLeafElem, QueryNodeElem, QueryElem, isLeafElem, isNodeElem } from '../../lib/Query'; import { QBQueryLeafElem } from './QBQueryLeafElem'; import { QBQueryNodeElem } from './QBQueryNodeElem'; export interface IProps { elem: QueryLeafElem | QueryNodeElem, + onReplace: (q: QueryElem) => void, } export function QBQueryElem(props: IProps) { let e = props.elem; - let renderLeaf = (l: any) => { - return - } - - if (e instanceof QueryLeafElem) { - return renderLeaf(e); - } else if (e instanceof QueryNodeElem) { - return + if (isLeafElem(e)) { + return + } else if (isNodeElem(e)) { + return } throw "Unsupported query element"; diff --git a/client/src/components/querybuilder/QBQueryElemMenu.tsx b/client/src/components/querybuilder/QBQueryElemMenu.tsx new file mode 100644 index 0000000..9d8ed8c --- /dev/null +++ b/client/src/components/querybuilder/QBQueryElemMenu.tsx @@ -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(''); + + 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/QBQueryLeafElem.tsx b/client/src/components/querybuilder/QBQueryLeafElem.tsx index 24dac07..d1625ae 100644 --- a/client/src/components/querybuilder/QBQueryLeafElem.tsx +++ b/client/src/components/querybuilder/QBQueryLeafElem.tsx @@ -1,9 +1,11 @@ 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 { QBQueryPlaceholder } from './QBQueryPlaceholder'; export interface LeafProps { elem: QueryLeafElem + onReplace: (q: QueryElem) => void } export function QBQueryElemArtistEquals(props: LeafProps) { @@ -20,9 +22,13 @@ export function QBQueryLeafElem(props: LeafProps) { let e = props.elem; if (e.a == QueryLeafBy.ArtistName && - e.op == QueryLeafOp.Equals && + e.leafOp == QueryLeafOp.Equals && typeof e.b == "string") { return + } else if (e.leafOp == QueryLeafOp.Placeholder) { + return } throw "Unsupported leaf element"; diff --git a/client/src/components/querybuilder/QBQueryNodeElem.tsx b/client/src/components/querybuilder/QBQueryNodeElem.tsx index a5a5614..6df3205 100644 --- a/client/src/components/querybuilder/QBQueryNodeElem.tsx +++ b/client/src/components/querybuilder/QBQueryNodeElem.tsx @@ -1,34 +1,41 @@ import React from 'react'; import QBOrBlock from './QBOrBlock'; 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 { elem: QueryNodeElem, - renderLeaf: (leaf: any) => any, + onReplace: (q: QueryElem) => void, } export function QBQueryNodeElem(props: NodeProps) { let e = props.elem; - if (e.op == QueryNodeOp.And) { - return - {e.operands.map((o: any) => { - if(o instanceof QueryNodeElem) { - return - } - return props.renderLeaf(o); - })} - - } else if (e.op == QueryNodeOp.Or) { - return - {e.operands.map((o: any) => { - if(o instanceof QueryNodeElem) { - return - } - return props.renderLeaf(o); - })} - + const onReplace = (idx: number, q: QueryElem) => { + var ops = e.operands; + ops[idx] = q; + let newNode = { operands: ops, nodeOp: e.nodeOp }; + props.onReplace(newNode); + } + + const children = e.operands.map((o: any, idx: number) => { + if (isNodeElem(o)) { + return onReplace(idx, q)} + /> + } + return onReplace(idx, q)} + /> + }); + + if (e.nodeOp == QueryNodeOp.And) { + return {children} + } else if (e.nodeOp == QueryNodeOp.Or) { + return {children} } throw "Unsupported node element"; diff --git a/client/src/components/querybuilder/QBQueryPlaceholder.tsx b/client/src/components/querybuilder/QBQueryPlaceholder.tsx new file mode 100644 index 0000000..23ea5f7 --- /dev/null +++ b/client/src/components/querybuilder/QBQueryPlaceholder.tsx @@ -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); + + const onOpen = (event: any) => { + setAnchorEl(event.currentTarget); + }; + const onClose = () => { + setAnchorEl(null); + }; + const onCreate = (q: QueryElem) => { + props.onReplace(q); + }; + + return <> + + + +} \ No newline at end of file diff --git a/client/src/components/querybuilder/QueryBuilder.tsx b/client/src/components/querybuilder/QueryBuilder.tsx index 2b43124..f8574e6 100644 --- a/client/src/components/querybuilder/QueryBuilder.tsx +++ b/client/src/components/querybuilder/QueryBuilder.tsx @@ -1,20 +1,40 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Box } from '@material-ui/core'; import QBQueryButton from './QBQueryButton'; import { QBQueryElem } from './QBQueryElem'; -import { QueryElem } from '../../lib/Query'; +import { QueryElem, addPlaceholders, removePlaceholders, simplify } from '../../lib/Query'; export interface IProps { - query: QueryElem + query: QueryElem | null, + onChangeQuery: (q: QueryElem | null) => void, } export default function QueryBuilder(props: IProps) { - return - - - - - + const [editing, setEditing] = useState(false); + + const simpleQuery = simplify(props.query); + const showQuery = editing ? + addPlaceholders(simpleQuery, null) : simpleQuery; + + const onReplace = (q: any) => { + const newQ = removePlaceholders(q); + props.onChangeQuery(newQ); + } + + return <> + + + setEditing(!editing)} + editing={editing} + /> + + + {showQuery && } + - + } \ No newline at end of file diff --git a/client/src/lib/Query.tsx b/client/src/lib/Query.tsx index f12578f..117c6da 100644 --- a/client/src/lib/Query.tsx +++ b/client/src/lib/Query.tsx @@ -1,3 +1,5 @@ +import { inflateRawSync } from "zlib"; + export enum QueryLeafBy { ArtistName = 0, AlbumName, @@ -8,56 +10,136 @@ export enum QueryLeafBy { export enum QueryLeafOp { Equals = 0, Like, + Placeholder, // Special op which indicates that this leaf is not filled in yet. } export type QueryLeafOperand = string | number; -export class QueryLeafElem { +export interface QueryLeafElem { a: QueryLeafBy; - op: QueryLeafOp; + leafOp: QueryLeafOp; 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 { And = 0, Or, } -export class QueryNodeElem { +export interface QueryNodeElem { operands: QueryElem[]; - op: QueryNodeOp; - - constructor( - operands: QueryElem[], - op: QueryNodeOp - ) { - this.operands = operands; - this.op = op; - } + nodeOp: QueryNodeOp; +} +export function isNodeElem(q: QueryElem): q is QueryNodeElem { + return 'nodeOp' in q; } + export function queryOr(...args: QueryElem[]) { - return new QueryNodeElem( - args, - QueryNodeOp.Or - ); + return { + operands: args, + nodeOp: QueryNodeOp.Or + } } export function queryAnd(...args: QueryElem[]) { - return new QueryNodeElem( - args, - QueryNodeOp.And - ); + return { + operands: args, + nodeOp: QueryNodeOp.And + }; +} + +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.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; } -export type QueryElem = QueryLeafElem | QueryNodeElem; \ No newline at end of file +// 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; +} \ No newline at end of file