From ad3dfeae7f6597d924f242ea664a72bfab650d92 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Fri, 7 Sep 2018 21:43:15 +0300 Subject: [PATCH] Implement presets new/edit functionality --- client/components/device-actions.js | 2 +- client/components/put-form.js | 125 +++++++++++++++++++ client/css/app.css | 11 +- client/devices-page.js | 2 +- client/presets-page.js | 184 ++++++++++++++++++++++------ client/store.js | 27 ++++ common/expression.js | 2 +- server/api-functions.js | 25 ++++ server/api.js | 45 ++++++- server/mongodb-functions.js | 50 +++++++- 10 files changed, 427 insertions(+), 46 deletions(-) create mode 100644 client/components/put-form.js diff --git a/client/components/device-actions.js b/client/components/device-actions.js index 523c123..e2df56b 100644 --- a/client/components/device-actions.js +++ b/client/components/device-actions.js @@ -83,7 +83,7 @@ const component = { ) ); - return m(".device-actions", buttons); + return m(".actions-bar", buttons); } }; diff --git a/client/components/put-form.js b/client/components/put-form.js new file mode 100644 index 0000000..bbc8eba --- /dev/null +++ b/client/components/put-form.js @@ -0,0 +1,125 @@ +"use strict"; + +import m from "mithril"; + +const singular = { + presets: "preset" +}; + +function createField(current, attr, focus) { + if (attr.type === "combo") { + let options = [m("option", { value: "" }, "--Please choose--")]; + for (let op of attr.options) options.push(m("option", { value: op }, op)); + + let selected = ""; + if (attr.options.includes(current.object[attr.id])) + selected = current.object[attr.id]; + return m( + "select", + { + name: attr.id, + value: selected, + oncreate: focus + ? _vnode => { + _vnode.dom.focus(); + } + : null, + onchange: e => { + current.object[attr.id] = e.target.value; + e.redraw = false; + } + }, + options + ); + } + + return m("input", { + type: "text", + name: attr.id, + disabled: attr.id === "_id" && !current.isNew, + value: current.object[attr.id], + oncreate: focus + ? _vnode => { + _vnode.dom.focus(); + } + : null, + oninput: e => { + current.object[attr.id] = e.target.value; + e.redraw = false; + } + }); +} + +const component = { + view: vnode => { + let actionHandler = vnode.attrs.actionHandler; + let attributes = vnode.attrs.attributes; + let resource = vnode.attrs.resource; + let base = vnode.attrs.base || {}; + if (!vnode.state.current) + vnode.state.current = { + isNew: !base["_id"], + object: Object.assign({}, base) + }; + + let current = vnode.state.current; + + let form = []; + let focused = false; + for (let attr of attributes) { + let focus = false; + if (!focused && (current.isNew || attr.id !== "_id")) + focus = focused = true; + + form.push( + m( + "p", + m("label", { for: attr.id }, attr.label || attr.id), + m("br"), + createField(current, attr, focus) + ) + ); + } + + let buttons = [m("button.primary", { type: "submit" }, "Save")]; + + if (!current.isNew) + buttons.push( + m( + "button.primary", + { + type: "button", + title: `Delete ${singular[resource]}`, + onclick: e => { + e.redraw = false; + e.target.disabled = true; + actionHandler("delete", current.object); + } + }, + "Delete" + ) + ); + + form.push(m(".actions-bar", buttons)); + + let children = [ + m("h1", `${current.isNew ? "New" : "Editing"} ${singular[resource]}`), + m( + "form", + { + onsubmit: e => { + e.target.disabled = true; + e.preventDefault(); + e.redraw = false; + actionHandler("save", current.object); + } + }, + form + ) + ]; + + return m("div.put-form", children); + } +}; + +export default component; diff --git a/client/css/app.css b/client/css/app.css index 5d477ca..f8f0788 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -355,6 +355,7 @@ button { } } +select, input { display: block; border: 1px solid var(--color1); @@ -519,7 +520,7 @@ span.na { clear: both; } -.device-actions { +.actions-bar { margin-top: 10px; & > button { margin-right: 10px; @@ -556,8 +557,8 @@ span.tag { justify-content: center; & > .overlay { - max-width: 90%; - max-height: 90%; + max-width: 90vw; + max-height: 90vh; background-color: white; border: 1px solid var(--color1); border-radius: 10px; @@ -565,3 +566,7 @@ span.tag { box-shadow: 0 0 15px #aaa; } } + +.overlay > .put-form { + min-width: 700px; +} diff --git a/client/devices-page.js b/client/devices-page.js index b53843e..fa1ae09 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -283,7 +283,7 @@ function renderActions(selected) { ) ); - return m(".device-actions", buttons); + return m(".actions-bar", buttons); } const component = { diff --git a/client/presets-page.js b/client/presets-page.js index 1acb843..ab4159b 100644 --- a/client/presets-page.js +++ b/client/presets-page.js @@ -4,28 +4,92 @@ import m from "mithril"; import config from "./config"; import filterComponent from "./filter-component"; +import * as overlay from "./overlay"; import * as store from "./store"; import * as expression from "../common/expression"; import * as notifications from "./notifications"; +import putForm from "./components/put-form"; import memoize from "../common/memoize"; const PAGE_SIZE = config.ui.pageSize || 10; const memoizedParse = memoize(expression.parse); +const attributes = [ + { id: "_id", label: "Name" }, + { id: "channel", label: "Channel" }, + { id: "weight", label: "Weight" }, + { id: "schedule", label: "Schedule" }, + { id: "events", label: "Events" }, + { id: "precondition", label: "Precondition" }, + { id: "provision", label: "Provision" }, + { id: "provisionArgs", label: "Arguments" } +]; + +function putActoinHandler(action, object, isNew) { + if (action === "save") { + let id = object["_id"]; + delete object["_id"]; + + if (!id) return notifications.push("error", "ID can not be empty"); + + store + .resourceExists("presets", id) + .then(exists => { + if (exists && isNew) { + notifications.push("error", "Preset already exists"); + store.fulfill(0, Date.now()); + return; + } + + if (!exists && !isNew) { + notifications.push("error", "Preset already deleted"); + store.fulfill(0, Date.now()); + return; + } + + store + .putResource("presets", id, object) + .then(() => { + notifications.push( + "success", + `Preset ${exists ? "updated" : "created"}` + ); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + }); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } else if (action === "delete") { + store + .deleteResource("presets", object["_id"]) + .then(() => { + notifications.push("success", "Preset deleted"); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } else { + throw new Error("Undefined action"); + } +} + +let formData = { + resource: "presets", + attributes: attributes +}; + const getDownloadUrl = memoize(filter => { + let cols = {}; + for (let attr of attributes) cols[attr.label] = attr.id; return `/api/presets.csv?${m.buildQueryString({ filter: filter, - columns: JSON.stringify({ - Name: "_id", - Channel: "channel", - Weight: "weight", - Schedule: "schedule", - Events: "events", - Precondition: "precondition", - Provision: "provision", - Arguments: "provisionArgs" - }) + columns: JSON.stringify(cols) })}`; }); @@ -58,17 +122,9 @@ function renderTable( disabled: !total }); - const labels = [ - selectAll, - "Name", - "Channel", - "Weight", - "Schedule", - "Events", - "Precondition", - "Provision", - "Arguments" - ].map(l => m("th", l)); + const labels = [selectAll] + .concat(attributes.map(elem => elem.label)) + .map(l => m("th", l)); let rows = []; for (let preset of presets) { @@ -91,6 +147,48 @@ function renderTable( filter: preset["precondition"] })}`; + let tds = [m("td", checkbox)]; + for (let attr of attributes) + if (attr.id === "precondition") + tds.push( + m( + "td", + { title: preset[attr.id] }, + m("a", { href: devicesUrl }, preset[attr.id]) + ) + ); + else tds.push(m("td", preset[attr.id])); + + tds.push( + m( + "td.table-row-links", + m( + "a", + { + onclick: () => { + let cb = () => { + return m( + putForm, + Object.assign( + { + base: preset, + actionHandler: (action, object) => { + overlay.close(cb); + putActoinHandler(action, object, false); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "Show" + ) + ) + ); + rows.push( m( "tr", @@ -104,19 +202,7 @@ function renderTable( if (!selected.delete(preset["_id"])) selected.add(preset["_id"]); } }, - m("td", checkbox), - m("td", preset["_id"]), - m("td", preset["channel"]), - m("td", preset["weight"]), - m("td", preset["schedule"]), - m("td", preset["events"]), - m( - "td", - { title: preset["precondition"] }, - m("a", { href: devicesUrl }, preset["precondition"]) - ), - m("td", preset["provision"]), - m("td", preset["provisionArgs"]) + tds ) ); } @@ -175,6 +261,34 @@ function renderTable( ) ]; + if (window.authorizer.hasAccess("presets", 3)) + buttons.push( + m( + "button.primary", + { + title: "Create new preset", + onclick: () => { + let cb = () => { + return m( + putForm, + Object.assign( + { + actionHandler: (action, object) => { + putActoinHandler(action, object, true); + overlay.close(cb); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "New" + ) + ); + return [ m( "table.table.highlight", @@ -182,7 +296,7 @@ function renderTable( m("tbody", rows), tfoot ), - buttons + m("div.actions-bar", buttons) ]; } diff --git a/client/store.js b/client/store.js index 5227ba4..f32764b 100644 --- a/client/store.js +++ b/client/store.js @@ -476,6 +476,31 @@ function deleteResource(resourceType, id) { }); } +function putResource(resourceType, id, object) { + for (let k in object) if (object[k] === undefined) object[k] = null; + + return m.request({ + method: "PUT", + url: `/api/${resourceType}/${encodeURIComponent(id)}`, + data: object + }); +} + +function resourceExists(resource, id) { + const param = resource === "devices" ? "DeviceID.ID" : "_id"; + let filter = ["=", ["PARAM", param], id]; + return m.request({ + method: "HEAD", + url: + `/api/${resource}/?` + + m.buildQueryString({ + filter: memoizedStringify(filter) + }), + extract: xhr => +xhr.getResponseHeader("x-total-count"), + background: true + }); +} + function evaluateExpression(exp, obj) { if (!Array.isArray(exp)) return exp; return memoizedEvaluate(exp, obj, fulfillTimestamp); @@ -512,6 +537,8 @@ export { postTasks, updateTags, deleteResource, + putResource, + resourceExists, evaluateExpression, logIn, logOut, diff --git a/common/expression.js b/common/expression.js index 77256dc..cf61052 100644 --- a/common/expression.js +++ b/common/expression.js @@ -63,8 +63,8 @@ function evaluate(exp, obj, now, cb) { if (e[1] == null) return null; if (obj && !isArray(e[1])) { let v = obj[e[1]]; - if (typeof v === "object") v = v.value ? v.value[0] : null; if (v == null) return null; + if (typeof v === "object") v = v.value ? v.value[0] : null; return v; } } else if (e[0] === "AND") { diff --git a/server/api-functions.js b/server/api-functions.js index 52794ce..f70b57c 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -7,6 +7,7 @@ const url = require("url"); const config = require("./config"); const db = require("./db"); const expression = require("../common/expression"); +const mongodbFunctions = require("./mongodb-functions"); function filterToMongoQuery(filter, negate = false, res = {}) { const op = filter[0]; @@ -283,8 +284,32 @@ function ping(host) { }); } +function putResource(resource, id, data) { + return new Promise((resolve, reject) => { + let options = url.parse( + `${config.server.nbi}${resource}/${encodeURIComponent(id)}` + ); + + options.method = "PUT"; + + let _http = options.protocol === "https:" ? https : http; + + if (resource === "presets") data = mongodbFunctions.preProcessPreset(data); + + let body = JSON.stringify(data); + _http + .request(options, res => { + res.resume(); + if (res.statusCode === 200) resolve(true); + else reject(new Error(`Unexpected status code ${res.statusCode}`)); + }) + .end(body); + }); +} + exports.postTasks = postTasks; exports.deleteResource = deleteResource; +exports.putResource = putResource; exports.updateTags = updateTags; exports.filterToMongoQuery = filterToMongoQuery; exports.ping = ping; diff --git a/server/api.js b/server/api.js index 0843fbd..3d4b7bb 100644 --- a/server/api.js +++ b/server/api.js @@ -16,6 +16,7 @@ function logUnauthorizedWarning(log) { } const RESOURCE_DELETE = 1 << 0; +const RESOURCE_PUT = 1 << 1; const RESOURCE_IDS = { devices: "DeviceID.ID", @@ -29,7 +30,7 @@ const RESOURCE_IDS = { const resources = { devices: 0 | RESOURCE_DELETE, - presets: 0 | RESOURCE_DELETE, + presets: 0 | RESOURCE_DELETE | RESOURCE_PUT, provisions: 0 | RESOURCE_DELETE, files: 0 | RESOURCE_DELETE, virtual_parameters: 0 | RESOURCE_DELETE, @@ -308,6 +309,48 @@ for (let [resource, flags] of Object.entries(resources)) { logger.accessInfo(log); + ctx.body = ""; + }); + + if (flags & RESOURCE_PUT) + router.put(`/${resource}/:id`, async (ctx, next) => { + const id = ctx.params.id; + + const log = { + message: `Put ${resource}`, + context: ctx, + id: id + }; + + const authorizer = ctx.state.authorizer; + if (!authorizer.hasAccess(resource, 3)) { + logUnauthorizedWarning(log); + return next(); + } + + let obj = ctx.request.body; + + const validate = authorizer.getValidator(resource, obj); + if (!validate("put")) { + logUnauthorizedWarning(log); + return (ctx.status = 403); + } + + await apiFunctions.putResource(resource, id, obj); + + logger.accessInfo(log); + + db.putAudit({ + username: ctx.state.user.username, + action: "put", + objectType: resource, + objectId: id + }).catch(err => { + setTimeout(() => { + throw err; + }, 0); + }); + ctx.body = ""; }); } diff --git a/server/mongodb-functions.js b/server/mongodb-functions.js index a470130..ae4c585 100644 --- a/server/mongodb-functions.js +++ b/server/mongodb-functions.js @@ -374,6 +374,11 @@ function mongoQueryToFilter(query) { } else { expressions.push(["IS NOT NULL", ["PARAM", `Tags.${v}`]]); } + } else if (k.startsWith("Tags.")) { + expressions.push([ + `IS${v["$exists"] ? " " : " NOT "}NULL`, + ["PARAM", k] + ]); } else if (typeof v === "object") { if (Array.isArray(v)) throw new Error(`Invalid type`); @@ -440,14 +445,50 @@ function flattenPreset(preset) { ) throw new Error("Invalid preset provision"); p.provision = provision.name; - p.provisionArgs = - provision.args && provision.args.length - ? `"${provision.args.join('" "')}"` - : ""; + p.provisionArgs = provision.args + ? JSON.stringify(provision.args).slice(1, -1) + : ""; delete p.configurations; return p; } +function preProcessPreset(data) { + let preset = Object.assign({}, data); + preset.precondition = preset.precondition + ? filterToMongoQuery(expression.parse(preset.precondition)) + : {}; + preset.precondition = JSON.stringify(preset.precondition); + + let events = {}; + if (preset.events) + for (let e of preset.events.split(",")) { + let v = true; + e = e.trim(); + if (e.startsWith("-")) { + v = false; + e = e.slice(1).trim(); + } + if (e) events[e] = v; + } + + preset.events = events; + + if (!preset.provision) throw new Error("Invalid preset provision"); + + let configuration = { + type: "provision", + name: preset.provision + }; + + if (preset.provisionArgs) + configuration.args = JSON.parse(`[${preset.provisionArgs}]`); + + delete preset.provision; + delete preset.provisionArgs; + preset.configurations = [configuration]; + return preset; +} + exports.processDeviceFilter = processDeviceFilter; exports.processTasksFilter = processTasksFilter; exports.processFaultsFilter = processFaultsFilter; @@ -458,3 +499,4 @@ exports.flattenDevice = flattenDevice; exports.flattenFault = flattenFault; exports.flattenTask = flattenTask; exports.flattenPreset = flattenPreset; +exports.preProcessPreset = preProcessPreset;