From 5b84b1daade57ce670b4f38f2cd394783788efbe Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Mon, 24 Sep 2018 13:24:01 -0700 Subject: [PATCH] Implement autocomplete and smart queries --- client/autocomplete-compnent.js | 113 ++++++++++++++++++++++++++++++++ client/css/app.css | 16 +++++ client/devices-page.js | 17 +++-- client/faults-page.js | 23 ++++--- client/filter-component.js | 44 +++++++++---- client/overlay.js | 2 +- client/presets-page.js | 21 +++--- client/provisions-page.js | 17 +++-- client/smart-query.js | 102 ++++++++++++++++++++++++++++ server/mongodb-functions.js | 23 ++++--- 10 files changed, 331 insertions(+), 47 deletions(-) create mode 100644 client/autocomplete-compnent.js create mode 100644 client/smart-query.js diff --git a/client/autocomplete-compnent.js b/client/autocomplete-compnent.js new file mode 100644 index 0000000..d4cb437 --- /dev/null +++ b/client/autocomplete-compnent.js @@ -0,0 +1,113 @@ +"use strict"; + +class Autocomplete { + constructor(className, callback) { + this.callback = callback; + this.element = null; + this.hideTimeout = null; + this.visible = false; + this.default = null; + + this.container = document.createElement("div"); + this.container.style.position = "absolute"; + this.container.style.display = "block"; + this.container.style.opacity = 0; + this.container.className = className; + } + + attach(el) { + el.setAttribute("autocomplete", "off"); + + el.addEventListener("focus", () => { + this.element = el; + let domRect = el.getBoundingClientRect(); + this.container.style.left = `${domRect.left}px`; + this.container.style.width = `${domRect.width}px`; + this.container.style.top = `${domRect.bottom}px`; + this.update(); + }); + + el.addEventListener("blur", () => { + if (this.element !== el) return; + if (!this.visible) return; + this.default = null; + this.hide(); + }); + + el.addEventListener("keydown", e => { + if (this.element !== el) return; + if (e.key === "Escape") this.hide(); + else if (e.key === "Enter") + if (this.default != null) { + el.value = this.default; + e.preventDefault(); + this.update(); + } + }); + + el.addEventListener("input", () => { + if (this.element !== el) return; + this.update(); + }); + } + + hide() { + this.container.style.opacity = 0; + this.visible = false; + this.default = null; + clearTimeout(this.hideTimeout); + this.hideTimeout = setTimeout(() => { + this.hideTimeout = null; + while (this.container.firstChild) + this.container.removeChild(this.container.firstChild); + document.body.removeChild(this.container); + }, 500); + } + + update() { + const el = this.element; + + this.callback(el.value, suggestions => { + if (this.element !== el) return; + this.default = null; + + if (!suggestions.length) { + if (this.visible) this.hide(); + + return; + } + + while (this.container.firstChild) + this.container.removeChild(this.container.firstChild); + + if (!this.visible) { + if (!this.hideTimeout) { + document.body.appendChild(this.container); + window.getComputedStyle(this.container).opacity; + } else { + clearTimeout(this.hideTimeout); + this.hideTimeout = null; + } + this.container.style.opacity = 1; + this.visible = true; + } + + this.default = suggestions[0]; + + for (let suggestion of suggestions) { + let e = document.createElement("div"); + e.className = "suggestion"; + let t = document.createTextNode(suggestion); + e.appendChild(t); + e.addEventListener("mousedown", ev => { + ev.preventDefault(); + el.value = suggestion; + if (this.element === el) this.update(el); + }); + this.container.appendChild(e); + } + }); + } +} + +export default Autocomplete; diff --git a/client/css/app.css b/client/css/app.css index 0486562..982dca5 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -587,3 +587,19 @@ span.tag { .overlay > .put-form { min-width: 700px; } + +.autocomplete { + box-shadow: 0 0 10px #ccc; + background-color: rgba(255, 255, 255, 0.9); + border: 1px solid var(--color1); + border-radius: 4px; + max-height: 300px; + overflow-y: auto; + transition: opacity var(--fade); + & > .suggestion { + padding: 5px; + &:hover { + background-color: var(--color3); + } + } +} diff --git a/client/devices-page.js b/client/devices-page.js index c8cc7fd..5b8da57 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -10,6 +10,8 @@ import * as taskQueue from "./task-queue"; import * as notifications from "./notifications"; import * as expression from "../common/expression"; import memoize from "../common/memoize"; +import * as smartQuery from "./smart-query"; +import * as expressionParser from "../common/expression-parser"; const PAGE_SIZE = config.ui.pageSize || 10; @@ -39,6 +41,14 @@ const getChildAttrs = memoize((attrs, device) => Object.assign({}, attrs, { device: device }) ); +const unpackSmartQuery = memoize(query => { + return expressionParser.map(query, e => { + if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q") + return smartQuery.unpack("devices", e[2], e[3]); + return e; + }); +}); + function init(args) { return new Promise((resolve, reject) => { if (!window.authorizer.hasAccess("devices", 2)) @@ -347,9 +357,8 @@ const component = { } const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; - const filter = vnode.attrs.filter - ? memoizedParse(vnode.attrs.filter) - : true; + let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; + filter = unpackSmartQuery(filter); let devs = store.fetch("devices", filter, { limit: vnode.state.showCount || PAGE_SIZE, @@ -372,7 +381,7 @@ const component = { return [ m("h1", "Listing devices"), m(filterComponent, { - predefined: Object.values(config.ui.filters), + resource: "devices", filter: vnode.attrs.filter, onChange: onFilterChanged }), diff --git a/client/faults-page.js b/client/faults-page.js index 9d090a6..07ac944 100644 --- a/client/faults-page.js +++ b/client/faults-page.js @@ -7,6 +7,8 @@ import * as store from "./store"; import * as notifications from "./notifications"; import * as expression from "../common/expression"; import memoize from "../common/memoize"; +import * as smartQuery from "./smart-query"; +import * as expressionParser from "../common/expression-parser"; const PAGE_SIZE = config.ui.pageSize || 10; @@ -33,6 +35,14 @@ const getDownloadUrl = memoize(filter => { })}`; }); +const unpackSmartQuery = memoize(query => { + return expressionParser.map(query, e => { + if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q") + return smartQuery.unpack("faults", e[2], e[3]); + return e; + }); +}); + function init(args) { if (!window.authorizer.hasAccess("faults", 2)) return Promise.reject( @@ -219,9 +229,8 @@ const component = { } const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; - const filter = vnode.attrs.filter - ? memoizedParse(vnode.attrs.filter) - : true; + let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; + filter = unpackSmartQuery(filter); let faults = store.fetch("faults", filter, { limit: vnode.state.showCount || PAGE_SIZE, @@ -240,13 +249,7 @@ const component = { return [ m("h1", "Listing faults"), m(filterComponent, { - predefined: [ - { parameter: "device" }, - { parameter: "channel" }, - { parameter: "code" }, - { parameter: "retries" }, - { parameter: "timestamp" } - ], + resource: "faults", filter: vnode.attrs.filter, onChange: onFilterChanged }), diff --git a/client/filter-component.js b/client/filter-component.js index f2e3e56..91b2249 100644 --- a/client/filter-component.js +++ b/client/filter-component.js @@ -3,14 +3,40 @@ import m from "mithril"; import * as expression from "../common/expression"; import memoize from "../common/memoize"; +import Autocomplete from "./autocomplete-compnent"; +import * as smartQuery from "./smart-query"; + +const getAutocomplete = memoize(resource => { + const labels = smartQuery.getLabels(resource); + const autocomplete = new Autocomplete("autocomplete", (txt, cb) => { + txt = txt.toLowerCase(); + cb(labels.filter(s => s.toLowerCase().includes(txt)).map(s => `${s}: `)); + }); + return autocomplete; +}); + +function parseFilter(f) { + if (/^[\s0-9a-zA-Z]+:/.test(f)) { + let k = f.split(":", 1)[0]; + let v = f.slice(k.length + 1).trim(); + return ["FUNC", "Q", k.trim(), v]; + } + return expression.parse(f); +} + +function stringifyFilter(f) { + if (Array.isArray(f) && f[0] === "FUNC" && f[1] === "Q") + return `${f[2]}: ${f[3]}`; + return expression.stringify(f); +} const splitFilter = memoize(filter => { if (!filter) return [""]; const list = []; const f = expression.parse(filter); if (Array.isArray(f) && f[0] === "AND") - for (let ff of f.slice(1)) list.push(expression.stringify(ff)); - else list.push(expression.stringify(f)); + for (let ff of f.slice(1)) list.push(stringifyFilter(ff)); + else list.push(stringifyFilter(f)); list.push(""); return list; @@ -18,12 +44,6 @@ const splitFilter = memoize(filter => { const component = { view: vnode => { - const predefined = vnode.attrs.predefined || []; - let a = m( - "datalist#filters", - predefined.map(f => m("option", { value: `${f.parameter} = ` })) - ); - if (!vnode.state.filterList || vnode.attrs.filter !== vnode.state.filter) { vnode.state.filterInvalid = 0; vnode.state.filter = vnode.attrs.filter; @@ -35,7 +55,7 @@ const component = { vnode.state.filterList = vnode.state.filterList.filter(f => f); let filter = vnode.state.filterList.map((f, idx) => { try { - return expression.parse(f); + return parseFilter(f); } catch (err) { vnode.state.filterInvalid |= 1 << idx; } @@ -56,17 +76,19 @@ const component = { return m( "div.filter", - [m("b", "Filter"), a].concat( + [m("b", "Filter")].concat( vnode.state.filterList.map((fltr, idx) => { return m("input", { type: "text", - list: "filters", class: `${(vnode.state.filterInvalid >> idx) & 1 ? "error" : ""}`, value: fltr, onchange: e => { vnode.state.filterList = vnode.state.filterList.slice(); vnode.state.filterList[idx] = e.target.value.trim(); onChange(); + }, + oncreate: vn => { + getAutocomplete(vnode.attrs.resource).attach(vn.dom); } }); }) diff --git a/client/overlay.js b/client/overlay.js index ae857a1..bca3be3 100644 --- a/client/overlay.js +++ b/client/overlay.js @@ -55,7 +55,7 @@ function render() { } document.addEventListener("keydown", e => { - if (e.keyCode === 27 && close(overlayCallback)) m.redraw(); + if (overlayCallback && e.keyCode === 27 && close(overlayCallback)) m.redraw(); }); window.addEventListener("popstate", () => { diff --git a/client/presets-page.js b/client/presets-page.js index badffb1..da826d5 100644 --- a/client/presets-page.js +++ b/client/presets-page.js @@ -10,6 +10,8 @@ import * as expression from "../common/expression"; import * as notifications from "./notifications"; import putForm from "./components/put-form"; import memoize from "../common/memoize"; +import * as smartQuery from "./smart-query"; +import * as expressionParser from "../common/expression-parser"; const PAGE_SIZE = config.ui.pageSize || 10; @@ -27,6 +29,14 @@ const attributes = [ { id: "provisionArgs", label: "Arguments", unsortable: true } ]; +const unpackSmartQuery = memoize(query => { + return expressionParser.map(query, e => { + if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q") + return smartQuery.unpack("presets", e[2], e[3]); + return e; + }); +}); + function putActoinHandler(action, object, isNew) { if (action === "save") { let id = object["_id"]; @@ -352,9 +362,8 @@ const component = { } const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; - const filter = vnode.attrs.filter - ? memoizedParse(vnode.attrs.filter) - : true; + let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; + filter = unpackSmartQuery(filter); let presets = store.fetch("presets", filter, { limit: vnode.state.showCount || PAGE_SIZE, @@ -384,11 +393,7 @@ const component = { return [ m("h1", "Listing presets"), m(filterComponent, { - predefined: [ - { parameter: "_id" }, - { parameter: "channel" }, - { parameter: "weight" } - ], + resource: "presets", filter: vnode.attrs.filter, onChange: onFilterChanged }), diff --git a/client/provisions-page.js b/client/provisions-page.js index b11e89f..2e4c20f 100644 --- a/client/provisions-page.js +++ b/client/provisions-page.js @@ -10,6 +10,8 @@ import * as notifications from "./notifications"; import memoize from "../common/memoize"; import putForm from "./components/put-form"; import * as overlay from "./overlay"; +import * as smartQuery from "./smart-query"; +import * as expressionParser from "../common/expression-parser"; const PAGE_SIZE = config.ui.pageSize || 10; @@ -21,6 +23,14 @@ const attributes = [ { id: "script", label: "Script", type: "code" } ]; +const unpackSmartQuery = memoize(query => { + return expressionParser.map(query, e => { + if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q") + return smartQuery.unpack("provisions", e[2], e[3]); + return e; + }); +}); + function putActoinHandler(action, object, isNew) { if (action === "save") { let id = object["_id"]; @@ -333,9 +343,8 @@ const component = { } const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; - const filter = vnode.attrs.filter - ? memoizedParse(vnode.attrs.filter) - : true; + let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true; + filter = unpackSmartQuery(filter); let provisions = store.fetch("provisions", filter, { limit: vnode.state.showCount || PAGE_SIZE, @@ -356,7 +365,7 @@ const component = { return [ m("h1", "Listing provisions"), m(filterComponent, { - predefined: [{ parameter: "_id" }], + resource: "provisions", filter: vnode.attrs.filter, onChange: onFilterChanged }), diff --git a/client/smart-query.js b/client/smart-query.js new file mode 100644 index 0000000..771e32d --- /dev/null +++ b/client/smart-query.js @@ -0,0 +1,102 @@ +"use strict"; + +import config from "./config"; +import * as expression from "../common/expression"; + +let resources = { + devices: {}, + faults: { + Device: { parameter: ["PARAM", "device"], type: "string" }, + Channel: { parameter: ["PARAM", "channel"], type: "string" }, + Code: { parameter: ["PARAM", "code"], type: "string" }, + Retries: { parameter: ["PARAM", "retries"], type: "number" }, + Timestamp: { parameter: ["PARAM", "timestamp"], type: "timestamp" } + }, + presets: { + ID: { parameter: ["PARAM", "_id"], type: "string" }, + Channel: { parameter: ["PARAM", "channel"], type: "string" }, + Weight: { parameter: ["PARAM", "weight"], type: "number" } + }, + provisions: { + ID: { parameter: ["PARAM", "_id"], type: "string" } + } +}; + +for (let v of Object.values(config.ui.filters)) + resources.devices[v.label] = { + parameter: expression.parse(v.parameter), + type: (v.type || "").split(",").map(s => s.trim()) + }; + +function getLabels(resource) { + if (!resources[resource]) return []; + return Object.keys(resources[resource]); +} + +function queryNumber(param, value) { + let op = "="; + for (let o of ["<>", "=", "<=", "<", ">=", ">"]) + if (value.startsWith(o)) { + op = o; + value = value.slice(o.length).trim(); + break; + } + + let v = parseInt(value); + if (v !== +value) return null; + + return [op, param, v]; +} + +function queryString(param, value) { + return ["LIKE", ["FUNC", "LOWER", param], value.toLowerCase()]; +} + +function queryMac(param, value) { + value = value.replace(/[^a-f0-9]/gi, "").toLowerCase(); + if (!value) return null; + if (value.length === 12) + return [ + "LIKE", + ["FUNC", "LOWER", param], + value.replace(/(..)(?!$)/g, "$1:") + ]; + + return [ + "OR", + [ + "LIKE", + ["FUNC", "LOWER", param], + `%${value.replace(/(..)(?!$)/g, "$1:")}%` + ], + ["LIKE", ["FUNC", "LOWER", param], `%${value.replace(/(.)(.)/g, "$1:$2")}%`] + ]; +} + +function unpack(resource, label, value) { + if (!resources[resource]) return null; + const type = resources[resource][label].type; + value = value.trim(); + let res = ["OR"]; + + if (type.length === 0 || type.includes("number")) { + let q = queryNumber(resources[resource][label].parameter, value); + if (q) res.push(q); + } + + if (type.length === 0 || type.includes("string")) { + let q = queryString(resources[resource][label].parameter, value); + if (q) res.push(q); + } + + if (type.includes("mac")) { + let q = queryMac(resources[resource][label].parameter, value); + if (q) res.push(q); + } + + if (res.length <= 1) return null; + else if (res.length === 2) return res[1]; + else return res; +} + +export { getLabels, unpack }; diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index 27e3aef..630f0d7 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -29,15 +29,20 @@ function processDeviceFilter(filter) { else if (exp[0] === "IS NOT NULL") return ["=", ["PARAM", "_tags"], t]; else if (exp[0] === "=" && exp[2] === true) return ["=", ["PARAM", "_tags"], t]; - } else if (["=", "<>", ">", ">=", "<", "<="].includes(exp[0])) { - let e = exp.slice(); - if ( - Array.isArray(exp[1]) && - exp[1][0] === "PARAM" && - typeof exp[1][1] === "string" && - !exp[1][1].startsWith("_") - ) - e[1] = ["PARAM", `${exp[1][1]}._value`]; + } else if ( + ["=", "<>", ">", ">=", "<", "<=", "LIKE", "NOT LIKE"].includes(exp[0]) + ) { + let e = expressionParser.map(exp, ee => { + if ( + Array.isArray(ee) && + ee[0] === "PARAM" && + typeof ee[1] === "string" && + !ee[1].startsWith("_") + ) + return ["PARAM", `${ee[1]}._value`]; + return ee; + }); + if (typeof e[2] === "number") { let alt = e.slice(); alt[2] = new Date(e[2]);