Implement autocomplete and smart queries

This commit is contained in:
Zaid Abdulla committed 2018-12-26 00:22:07 -08:00
1 parent 518254aa55
commit 5b84b1daad
10 files changed
+331 -47

No files matched your search

+113
View File
@@ -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;
+16
View File
@@ -587,3 +587,19 @@ span.tag {
.overlay > .put-form { .overlay > .put-form {
min-width: 700px; 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);
}
}
}
+13 -4
View File
@@ -10,6 +10,8 @@ import * as taskQueue from "./task-queue";
import * as notifications from "./notifications"; import * as notifications from "./notifications";
import * as expression from "../common/expression"; import * as expression from "../common/expression";
import memoize from "../common/memoize"; 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; const PAGE_SIZE = config.ui.pageSize || 10;
@@ -39,6 +41,14 @@ const getChildAttrs = memoize((attrs, device) =>
Object.assign({}, attrs, { device: 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) { function init(args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!window.authorizer.hasAccess("devices", 2)) if (!window.authorizer.hasAccess("devices", 2))
@@ -347,9 +357,8 @@ const component = {
} }
const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {};
const filter = vnode.attrs.filter let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true;
? memoizedParse(vnode.attrs.filter) filter = unpackSmartQuery(filter);
: true;
let devs = store.fetch("devices", filter, { let devs = store.fetch("devices", filter, {
limit: vnode.state.showCount || PAGE_SIZE, limit: vnode.state.showCount || PAGE_SIZE,
@@ -372,7 +381,7 @@ const component = {
return [ return [
m("h1", "Listing devices"), m("h1", "Listing devices"),
m(filterComponent, { m(filterComponent, {
predefined: Object.values(config.ui.filters), resource: "devices",
filter: vnode.attrs.filter, filter: vnode.attrs.filter,
onChange: onFilterChanged onChange: onFilterChanged
}), }),
+13 -10
View File
@@ -7,6 +7,8 @@ import * as store from "./store";
import * as notifications from "./notifications"; import * as notifications from "./notifications";
import * as expression from "../common/expression"; import * as expression from "../common/expression";
import memoize from "../common/memoize"; 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; 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) { function init(args) {
if (!window.authorizer.hasAccess("faults", 2)) if (!window.authorizer.hasAccess("faults", 2))
return Promise.reject( return Promise.reject(
@@ -219,9 +229,8 @@ const component = {
} }
const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {};
const filter = vnode.attrs.filter let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true;
? memoizedParse(vnode.attrs.filter) filter = unpackSmartQuery(filter);
: true;
let faults = store.fetch("faults", filter, { let faults = store.fetch("faults", filter, {
limit: vnode.state.showCount || PAGE_SIZE, limit: vnode.state.showCount || PAGE_SIZE,
@@ -240,13 +249,7 @@ const component = {
return [ return [
m("h1", "Listing faults"), m("h1", "Listing faults"),
m(filterComponent, { m(filterComponent, {
predefined: [ resource: "faults",
{ parameter: "device" },
{ parameter: "channel" },
{ parameter: "code" },
{ parameter: "retries" },
{ parameter: "timestamp" }
],
filter: vnode.attrs.filter, filter: vnode.attrs.filter,
onChange: onFilterChanged onChange: onFilterChanged
}), }),
+33 -11
View File
@@ -3,14 +3,40 @@
import m from "mithril"; import m from "mithril";
import * as expression from "../common/expression"; import * as expression from "../common/expression";
import memoize from "../common/memoize"; 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 => { const splitFilter = memoize(filter => {
if (!filter) return [""]; if (!filter) return [""];
const list = []; const list = [];
const f = expression.parse(filter); const f = expression.parse(filter);
if (Array.isArray(f) && f[0] === "AND") if (Array.isArray(f) && f[0] === "AND")
for (let ff of f.slice(1)) list.push(expression.stringify(ff)); for (let ff of f.slice(1)) list.push(stringifyFilter(ff));
else list.push(expression.stringify(f)); else list.push(stringifyFilter(f));
list.push(""); list.push("");
return list; return list;
@@ -18,12 +44,6 @@ const splitFilter = memoize(filter => {
const component = { const component = {
view: vnode => { 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) { if (!vnode.state.filterList || vnode.attrs.filter !== vnode.state.filter) {
vnode.state.filterInvalid = 0; vnode.state.filterInvalid = 0;
vnode.state.filter = vnode.attrs.filter; vnode.state.filter = vnode.attrs.filter;
@@ -35,7 +55,7 @@ const component = {
vnode.state.filterList = vnode.state.filterList.filter(f => f); vnode.state.filterList = vnode.state.filterList.filter(f => f);
let filter = vnode.state.filterList.map((f, idx) => { let filter = vnode.state.filterList.map((f, idx) => {
try { try {
return expression.parse(f); return parseFilter(f);
} catch (err) { } catch (err) {
vnode.state.filterInvalid |= 1 << idx; vnode.state.filterInvalid |= 1 << idx;
} }
@@ -56,17 +76,19 @@ const component = {
return m( return m(
"div.filter", "div.filter",
[m("b", "Filter"), a].concat( [m("b", "Filter")].concat(
vnode.state.filterList.map((fltr, idx) => { vnode.state.filterList.map((fltr, idx) => {
return m("input", { return m("input", {
type: "text", type: "text",
list: "filters",
class: `${(vnode.state.filterInvalid >> idx) & 1 ? "error" : ""}`, class: `${(vnode.state.filterInvalid >> idx) & 1 ? "error" : ""}`,
value: fltr, value: fltr,
onchange: e => { onchange: e => {
vnode.state.filterList = vnode.state.filterList.slice(); vnode.state.filterList = vnode.state.filterList.slice();
vnode.state.filterList[idx] = e.target.value.trim(); vnode.state.filterList[idx] = e.target.value.trim();
onChange(); onChange();
},
oncreate: vn => {
getAutocomplete(vnode.attrs.resource).attach(vn.dom);
} }
}); });
}) })
+1 -1
View File
@@ -55,7 +55,7 @@ function render() {
} }
document.addEventListener("keydown", e => { document.addEventListener("keydown", e => {
if (e.keyCode === 27 && close(overlayCallback)) m.redraw(); if (overlayCallback && e.keyCode === 27 && close(overlayCallback)) m.redraw();
}); });
window.addEventListener("popstate", () => { window.addEventListener("popstate", () => {
+13 -8
View File
@@ -10,6 +10,8 @@ import * as expression from "../common/expression";
import * as notifications from "./notifications"; import * as notifications from "./notifications";
import putForm from "./components/put-form"; import putForm from "./components/put-form";
import memoize from "../common/memoize"; 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; const PAGE_SIZE = config.ui.pageSize || 10;
@@ -27,6 +29,14 @@ const attributes = [
{ id: "provisionArgs", label: "Arguments", unsortable: true } { 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) { function putActoinHandler(action, object, isNew) {
if (action === "save") { if (action === "save") {
let id = object["_id"]; let id = object["_id"];
@@ -352,9 +362,8 @@ const component = {
} }
const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {};
const filter = vnode.attrs.filter let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true;
? memoizedParse(vnode.attrs.filter) filter = unpackSmartQuery(filter);
: true;
let presets = store.fetch("presets", filter, { let presets = store.fetch("presets", filter, {
limit: vnode.state.showCount || PAGE_SIZE, limit: vnode.state.showCount || PAGE_SIZE,
@@ -384,11 +393,7 @@ const component = {
return [ return [
m("h1", "Listing presets"), m("h1", "Listing presets"),
m(filterComponent, { m(filterComponent, {
predefined: [ resource: "presets",
{ parameter: "_id" },
{ parameter: "channel" },
{ parameter: "weight" }
],
filter: vnode.attrs.filter, filter: vnode.attrs.filter,
onChange: onFilterChanged onChange: onFilterChanged
}), }),
+13 -4
View File
@@ -10,6 +10,8 @@ import * as notifications from "./notifications";
import memoize from "../common/memoize"; import memoize from "../common/memoize";
import putForm from "./components/put-form"; import putForm from "./components/put-form";
import * as overlay from "./overlay"; 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; const PAGE_SIZE = config.ui.pageSize || 10;
@@ -21,6 +23,14 @@ const attributes = [
{ id: "script", label: "Script", type: "code" } { 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) { function putActoinHandler(action, object, isNew) {
if (action === "save") { if (action === "save") {
let id = object["_id"]; let id = object["_id"];
@@ -333,9 +343,8 @@ const component = {
} }
const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {}; const sort = vnode.attrs.sort ? memoizedJsonParse(vnode.attrs.sort) : {};
const filter = vnode.attrs.filter let filter = vnode.attrs.filter ? memoizedParse(vnode.attrs.filter) : true;
? memoizedParse(vnode.attrs.filter) filter = unpackSmartQuery(filter);
: true;
let provisions = store.fetch("provisions", filter, { let provisions = store.fetch("provisions", filter, {
limit: vnode.state.showCount || PAGE_SIZE, limit: vnode.state.showCount || PAGE_SIZE,
@@ -356,7 +365,7 @@ const component = {
return [ return [
m("h1", "Listing provisions"), m("h1", "Listing provisions"),
m(filterComponent, { m(filterComponent, {
predefined: [{ parameter: "_id" }], resource: "provisions",
filter: vnode.attrs.filter, filter: vnode.attrs.filter,
onChange: onFilterChanged onChange: onFilterChanged
}), }),
+102
View File
@@ -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 };
+14 -9
View File
@@ -29,15 +29,20 @@ function processDeviceFilter(filter) {
else if (exp[0] === "IS NOT NULL") return ["=", ["PARAM", "_tags"], t]; else if (exp[0] === "IS NOT NULL") return ["=", ["PARAM", "_tags"], t];
else if (exp[0] === "=" && exp[2] === true) else if (exp[0] === "=" && exp[2] === true)
return ["=", ["PARAM", "_tags"], t]; return ["=", ["PARAM", "_tags"], t];
} else if (["=", "<>", ">", ">=", "<", "<="].includes(exp[0])) { } else if (
let e = exp.slice(); ["=", "<>", ">", ">=", "<", "<=", "LIKE", "NOT LIKE"].includes(exp[0])
if ( ) {
Array.isArray(exp[1]) && let e = expressionParser.map(exp, ee => {
exp[1][0] === "PARAM" && if (
typeof exp[1][1] === "string" && Array.isArray(ee) &&
!exp[1][1].startsWith("_") ee[0] === "PARAM" &&
) typeof ee[1] === "string" &&
e[1] = ["PARAM", `${exp[1][1]}._value`]; !ee[1].startsWith("_")
)
return ["PARAM", `${ee[1]}._value`];
return ee;
});
if (typeof e[2] === "number") { if (typeof e[2] === "number") {
let alt = e.slice(); let alt = e.slice();
alt[2] = new Date(e[2]); alt[2] = new Date(e[2]);