Implement autocomplete and smart queries
This commit is contained in:
1 parent
518254aa55
commit
5b84b1daad
10 files changed
+329
-45
No files matched your search
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -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
|
||||
}),
|
||||
|
||||
+13
-10
@@ -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
|
||||
}),
|
||||
|
||||
+33
-11
@@ -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);
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
+1
-1
@@ -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", () => {
|
||||
|
||||
+13
-8
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
} else if (
|
||||
["=", "<>", ">", ">=", "<", "<=", "LIKE", "NOT LIKE"].includes(exp[0])
|
||||
) {
|
||||
let e = expressionParser.map(exp, ee => {
|
||||
if (
|
||||
Array.isArray(exp[1]) &&
|
||||
exp[1][0] === "PARAM" &&
|
||||
typeof exp[1][1] === "string" &&
|
||||
!exp[1][1].startsWith("_")
|
||||
Array.isArray(ee) &&
|
||||
ee[0] === "PARAM" &&
|
||||
typeof ee[1] === "string" &&
|
||||
!ee[1].startsWith("_")
|
||||
)
|
||||
e[1] = ["PARAM", `${exp[1][1]}._value`];
|
||||
return ["PARAM", `${ee[1]}._value`];
|
||||
return ee;
|
||||
});
|
||||
|
||||
if (typeof e[2] === "number") {
|
||||
let alt = e.slice();
|
||||
alt[2] = new Date(e[2]);
|
||||
|
||||
Reference in new issue
Block a user