diff --git a/ui/devices-page.ts b/ui/devices-page.ts index e9b6fac..01d6d5a 100644 --- a/ui/devices-page.ts +++ b/ui/devices-page.ts @@ -1,12 +1,13 @@ import { ClosureComponent, Component, Children } from "mithril"; import { m } from "./components"; import config from "./config"; +import indexTableComponent from "./index-table-component"; import filterComponent from "./filter-component"; import * as store from "./store"; import * as taskQueue from "./task-queue"; import * as notifications from "./notifications"; -import { evaluate, extractParams } from "../lib/common/expression"; import { parse, stringify } from "../lib/common/expression-parser"; +import { evaluate, extractParams } from "../lib/common/expression"; import memoize from "../lib/common/memoize"; import * as smartQuery from "./smart-query"; import * as expressionParser from "../lib/common/expression-parser"; @@ -53,147 +54,6 @@ export function init(args): Promise<{}> { }); } -function renderTable( - devicesResponse, - parameters, - total, - selected, - showMoreCallback, - downloadUrl, - sort, - onSortChange -): Children { - const devices = devicesResponse.value; - const selectAll = m("input", { - type: "checkbox", - checked: devices.length && selected.size === devices.length, - onchange: e => { - for (const d of devices) { - if (e.target.checked) selected.add(d["DeviceID.ID"].value[0]); - else selected.delete(d["DeviceID.ID"].value[0]); - } - }, - disabled: !total - }); - - const labels = [m("th", selectAll)]; - for (const param of parameters) { - const label = param.label; - let _param; - if (!param.unsortable && (_param = memoizedGetSortable(param.parameter))) { - let direction = 1; - - let symbol = "\u2981"; - if (sort[_param] > 0) symbol = "\u2bc6"; - else if (sort[_param] < 0) symbol = "\u2bc5"; - - const sortable = m( - "button", - { - onclick: () => { - if (sort[_param] > 0) direction *= -1; - return onSortChange(JSON.stringify({ [_param]: direction })); - } - }, - symbol - ); - - labels.push(m("th", [label, sortable])); - } else { - labels.push(m("th", label)); - } - } - - const rows = []; - for (const device of devices) { - const checkbox = m("input", { - type: "checkbox", - checked: selected.has(device["DeviceID.ID"].value[0]), - onchange: e => { - if (e.target.checked) selected.add(device["DeviceID.ID"].value[0]); - else selected.delete(device["DeviceID.ID"].value[0]); - } - }); - - rows.push( - m( - "tr", - { - onclick: e => { - if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { - e.redraw = false; - return; - } - - if (!selected.delete(device["DeviceID.ID"].value[0])) - selected.add(device["DeviceID.ID"].value[0]); - } - }, - m("td", checkbox), - parameters.map(p => { - const comp = m.context( - { device: device, parameter: p.parameter }, - p.type || "parameter", - p - ); - return m("td", comp); - }), - m( - "td.table-row-links", - m( - "a", - { - href: `#!/devices/${encodeURIComponent( - device["DeviceID.ID"].value[0] - )}` - }, - "Show" - ) - ) - ) - ); - } - - if (!rows.length) { - rows.push( - m("tr.empty", m("td", { colspan: parameters.length + 1 }, "No devices")) - ); - } - - const footerElements = []; - if (total != null) footerElements.push(`${devices.length}/${total}`); - else footerElements.push(`${devices.length}`); - - footerElements.push( - m( - "button", - { - title: "Show more devices", - onclick: showMoreCallback, - disabled: devices.length >= total || !devicesResponse.fulfilled - }, - "More" - ) - ); - - if (downloadUrl) { - footerElements.push( - m("a.download-csv", { href: downloadUrl, download: "" }, "Download") - ); - } - - const tfoot = m( - "tfoot", - m("tr", m("td", { colspan: labels.length }, footerElements)) - ); - - return m("table.table.highlight", [ - m("thead", m("tr", labels)), - m("tbody", rows), - tfoot - ]); -} - function renderActions(selected): Children { const buttons = []; @@ -332,13 +192,14 @@ function renderActions(selected): Children { ) ); - return m(".actions-bar", buttons); + return buttons; } export const component: ClosureComponent = (): Component => { return { view: vnode => { document.title = "Devices - GenieACS"; + const attributes = vnode.attrs["indexParameters"]; function showMore(): void { vnode.state["showCount"] = @@ -352,15 +213,34 @@ export const component: ClosureComponent = (): Component => { m.route.set("/devices", ops); } - function onSortChange(sort): void { - const ops = { sort }; - if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"]; - m.route.set("/devices", ops); - } - const sort = vnode.attrs["sort"] ? memoizedJsonParse(vnode.attrs["sort"]) : {}; + + const sortAttributes = {}; + for (let i = 0; i < attributes.length; i++) { + const attr = attributes[i]; + if (attr.unsortable) continue; + const param = memoizedGetSortable(attr.parameter); + if (param) sortAttributes[i] = sort[param] || 0; + } + + function onSortChange(sortedAttrs): void { + const _sort = Object.assign({}, sort); + for (const [index, direction] of Object.entries(sortedAttrs)) { + const param = memoizedGetSortable(attributes[index].parameter); + if (param) { + // Changing the priority of columns + delete _sort[param]; + _sort[param] = direction; + } + } + + const ops = { sort: JSON.stringify(_sort) }; + if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"]; + m.route.set(m.route.get(), ops); + } + let filter = vnode.attrs["filter"] ? memoizedParse(vnode.attrs["filter"]) : true; @@ -372,39 +252,49 @@ export const component: ClosureComponent = (): Component => { }); const count = store.count("devices", filter); - const selected = new Set(); - if (vnode.state["selected"]) { - for (const d of devs.value) { - if (vnode.state["selected"].has(d["DeviceID.ID"].value[0])) - selected.add(d["DeviceID.ID"].value[0]); - } - } - vnode.state["selected"] = selected; + const downloadUrl = getDownloadUrl(filter, attributes); - const downloadUrl = getDownloadUrl( - filter, - vnode.attrs["indexParameters"] - ); + const valueCallback = (attr, device): Children => { + return m.context( + { device: device, parameter: attr.parameter }, + attr.type || "parameter", + attr + ); + }; const attrs = {}; - attrs["resource"] = "devices"; - attrs["filter"] = vnode.attrs["filter"]; - attrs["onChange"] = onFilterChanged; + attrs["attributes"] = attributes; + attrs["data"] = devs.value; + attrs["total"] = count.value; + attrs["showMoreCallback"] = showMore; + attrs["sortAttributes"] = sortAttributes; + attrs["onSortChange"] = onSortChange; + attrs["downloadUrl"] = downloadUrl; + attrs["valueCallback"] = valueCallback; + attrs["recordActionsCallback"] = (device): Children => { + return m( + "a", + { + href: `#!/devices/${encodeURIComponent( + device["DeviceID.ID"].value[0] + )}` + }, + "Show" + ); + }; + + if (window.authorizer.hasAccess("devices", 3)) + attrs["actionsCallback"] = renderActions; + + const filterAttrs = {}; + filterAttrs["resource"] = "devices"; + filterAttrs["filter"] = vnode.attrs["filter"]; + filterAttrs["onChange"] = onFilterChanged; return [ m("h1", "Listing devices"), - m(filterComponent, attrs), - renderTable( - devs, - vnode.attrs["indexParameters"], - count.value, - selected, - showMore, - downloadUrl, - sort, - onSortChange - ), - renderActions(selected) + m(filterComponent, filterAttrs), + m(indexTableComponent, attrs) ]; } }; diff --git a/ui/faults-page.ts b/ui/faults-page.ts index 3f3bb0b..13cecb2 100644 --- a/ui/faults-page.ts +++ b/ui/faults-page.ts @@ -1,6 +1,7 @@ import { ClosureComponent, Component, Children } from "mithril"; import { m } from "./components"; import config from "./config"; +import indexTableComponent from "./index-table-component"; import filterComponent from "./filter-component"; import * as store from "./store"; import * as notifications from "./notifications"; @@ -19,7 +20,7 @@ const attributes = [ { id: "channel", label: "Channel" }, { id: "code", label: "Code" }, { id: "message", label: "Message" }, - { id: "detail", label: "Detail", unsortable: true }, + { id: "detail", label: "Detail" }, { id: "retries", label: "Retries" }, { id: "timestamp", label: "Timestamp" } ]; @@ -63,171 +64,6 @@ export function init(args): Promise<{}> { }); } -function renderTable( - faultsResponse, - total, - selected, - showMoreCallback, - downloadUrl, - sort, - onSortChange -): Children { - const faults = faultsResponse.value; - const selectAll = m("input", { - type: "checkbox", - checked: faults.length && selected.size === faults.length, - onchange: e => { - for (const f of faults) { - if (e.target.checked) selected.add(f["_id"]); - else selected.delete(f["_id"]); - } - }, - disabled: !total - }); - - const labels = [m("th", selectAll)]; - for (const attr of attributes) { - const label = attr.label; - - if (attr.unsortable) { - labels.push(m("th", label)); - continue; - } - - // Offset direction by 1 since sort["_id"] = -1 - let direction = 2; - - let symbol = "\u2981"; - if (sort[attr.id] > 0) symbol = "\u2bc6"; - else if (sort[attr.id] < 0) symbol = "\u2bc5"; - - const sortable = m( - "button", - { - onclick: () => { - if (sort[attr.id] > 0) direction *= -1; - return onSortChange(JSON.stringify({ [attr.id]: direction })); - } - }, - symbol - ); - - labels.push(m("th", [label, sortable])); - } - - const rows = []; - for (const f of faults) { - const checkbox = m("input", { - type: "checkbox", - checked: selected.has(f["_id"]), - onchange: e => { - if (e.target.checked) selected.add(f["_id"]); - else selected.delete(f["_id"]); - }, - onclick: e => { - e.stopPropagation(); - e.redraw = false; - } - }); - - const deviceHref = `#!/devices/${encodeURIComponent(f["device"])}`; - - const tds = [m("td", checkbox)]; - for (const attr of attributes) { - if (attr.id === "device") - tds.push(m("td", m("a", { href: deviceHref }, f[attr.id]))); - else if (attr.id === "timestamp") - tds.push(m("td", new Date(f[attr.id]).toLocaleString())); - else if (attr.id === "detail") - tds.push(m("td", m("long-text", { text: yaml.stringify(f[attr.id]) }))); - else tds.push(m("td", f[attr.id])); - } - - rows.push( - m( - "tr", - { - onclick: e => { - if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { - e.redraw = false; - return; - } - - if (!selected.delete(f["_id"])) selected.add(f["_id"]); - } - }, - tds - ) - ); - } - - if (!rows.length) - rows.push(m("tr.empty", m("td", { colspan: 7 }, "No faults"))); - - const footerElements = []; - if (total != null) footerElements.push(`${faults.length}/${total}`); - else footerElements.push(`${faults.length}`); - - footerElements.push( - m( - "button", - { - title: "Show more faults", - onclick: showMoreCallback, - disabled: faults.length >= total || !faultsResponse.fulfilled - }, - "More" - ) - ); - - if (downloadUrl) { - footerElements.push( - m("a.download-csv", { href: downloadUrl, download: "" }, "Download") - ); - } - - const tfoot = m( - "tfoot", - m("tr", m("td", { colspan: labels.length }, footerElements)) - ); - - const buttons = [ - m( - "button.primary", - { - title: "Delete selected faults", - disabled: !selected.size, - onclick: e => { - e.redraw = false; - e.target.disabled = true; - Promise.all( - Array.from(selected).map(id => store.deleteResource("faults", id)) - ) - .then(res => { - notifications.push("success", `${res.length} faults deleted`); - store.fulfill(0, Date.now()); - }) - .catch(err => { - notifications.push("error", err.message); - store.fulfill(0, Date.now()); - }); - } - }, - "Delete" - ) - ]; - - return [ - m( - "table.table.highlight", - m("thead", m("tr", labels)), - m("tbody", rows), - tfoot - ), - buttons - ]; -} - export const component: ClosureComponent = (): Component => { return { view: vnode => { @@ -245,15 +81,29 @@ export const component: ClosureComponent = (): Component => { m.route.set("/faults", ops); } - function onSortChange(sort): void { - const ops = { sort }; - if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"]; - m.route.set("/faults", ops); - } - const sort = vnode.attrs["sort"] ? memoizedJsonParse(vnode.attrs["sort"]) : {}; + + const sortAttributes = {}; + for (let i = 0; i < attributes.length; i++) { + const attr = attributes[i]; + if (attr.id !== "detail") sortAttributes[i] = sort[attr.id] || 0; + } + + function onSortChange(sortAttrs): void { + const _sort = Object.assign({}, sort); + for (const [index, direction] of Object.entries(sortAttrs)) { + // Changing the priority of columns + delete _sort[attributes[index].id]; + _sort[attributes[index].id] = direction; + } + + const ops = { sort: JSON.stringify(_sort) }; + if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"]; + m.route.set(m.route.get(), ops); + } + let filter = vnode.attrs["filter"] ? memoizedParse(vnode.attrs["filter"]) : true; @@ -265,32 +115,82 @@ export const component: ClosureComponent = (): Component => { }); const count = store.count("faults", filter); - const selected = new Set(); - if (vnode.state["selected"]) { - for (const f of faults.value) - if (vnode.state["selected"].has(f["_id"])) selected.add(f["_id"]); - } - vnode.state["selected"] = selected; - const downloadUrl = getDownloadUrl(filter); + const valueCallback = (attr, fault): Children => { + if (attr.id === "device") { + const deviceHref = `#!/devices/${encodeURIComponent( + fault["device"] + )}`; + + return m("a", { href: deviceHref }, fault["device"]); + } + + if (attr.id === "detail") + return m("long-text", { text: yaml.stringify(fault["detail"]) }); + + if (attr.id === "timestamp") + return new Date(fault["timestamp"]).toLocaleString(); + + return fault[attr.id]; + }; + const attrs = {}; - attrs["resource"] = "faults"; - attrs["filter"] = vnode.attrs["filter"]; - attrs["onChange"] = onFilterChanged; + attrs["attributes"] = attributes; + attrs["data"] = faults.value; + attrs["valueCallback"] = valueCallback; + attrs["total"] = count.value; + attrs["showMoreCallback"] = showMore; + attrs["sortAttributes"] = sortAttributes; + attrs["onSortChange"] = onSortChange; + attrs["downloadUrl"] = downloadUrl; + + if (window.authorizer.hasAccess("faults", 3)) { + attrs["actionsCallback"] = (selected): Children => { + return m( + "button.primary", + { + disabled: selected.size === 0, + title: "Delete selected faults", + onclick: e => { + e.redraw = false; + e.target.disabled = true; + + if (!confirm(`Deleting ${selected.size} faults. Are you sure?`)) + return; + + Promise.all( + Array.from(selected).map(id => + store.deleteResource("faults", id) + ) + ) + .then(res => { + notifications.push( + "success", + `${res.length} faults deleted` + ); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + store.fulfill(0, Date.now()); + }); + } + }, + "Delete" + ); + }; + } + + const filterAttrs = {}; + filterAttrs["resource"] = "faults"; + filterAttrs["filter"] = vnode.attrs["filter"]; + filterAttrs["onChange"] = onFilterChanged; return [ m("h1", "Listing faults"), - m(filterComponent, attrs), - renderTable( - faults, - count.value, - selected, - showMore, - downloadUrl, - sort, - onSortChange - ) + m(filterComponent, filterAttrs), + m(indexTableComponent, attrs) ]; } }; diff --git a/ui/files-page.ts b/ui/files-page.ts index 8d19025..a8c6955 100644 --- a/ui/files-page.ts +++ b/ui/files-page.ts @@ -6,6 +6,7 @@ import * as store from "./store"; import * as notifications from "./notifications"; import memoize from "../lib/common/memoize"; import putFormComponent from "./put-form-component"; +import indexTableComponent from "./index-table-component"; import * as overlay from "./overlay"; import * as smartQuery from "./smart-query"; import { map, parse, stringify } from "../lib/common/expression-parser"; @@ -129,205 +130,6 @@ export function init(args): Promise<{}> { return Promise.resolve({ filter, sort }); } -function renderTable( - filesResponse, - total, - selected, - showMoreCallback, - downloadUrl, - sort, - onSortChange -): Children { - const files = filesResponse.value; - const selectAll = m("input", { - type: "checkbox", - checked: files.length && selected.size === files.length, - onchange: e => { - for (const file of files) { - if (e.target.checked) selected.add(file["_id"]); - else selected.delete(file["_id"]); - } - }, - disabled: !total - }); - - const labels = [m("th", selectAll)]; - for (const attr of attributes) { - const label = attr.label; - - let direction = 1; - - let symbol = "\u2981"; - if (sort[attr.id] > 0) symbol = "\u2bc6"; - else if (sort[attr.id] < 0) symbol = "\u2bc5"; - - const sortable = m( - "button", - { - onclick: () => { - if (sort[attr.id] > 0) direction *= -1; - return onSortChange(JSON.stringify({ [attr.id]: direction })); - } - }, - symbol - ); - - labels.push(m("th", [label, sortable])); - } - - const rows = []; - for (const file of files) { - const checkbox = m("input", { - type: "checkbox", - checked: selected.has(file["_id"]), - onchange: e => { - if (e.target.checked) selected.add(file["_id"]); - else selected.delete(file["_id"]); - }, - onclick: e => { - e.stopPropagation(); - e.redraw = false; - } - }); - - const tds = [m("td", checkbox)]; - for (const attr of attributes) { - if (attr.id === "script") - tds.push(m("td", { title: file[attr.id] }, file[attr.id])); - else tds.push(m("td", file[attr.id])); - } - - rows.push( - m( - "tr", - { - onclick: e => { - if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { - e.redraw = false; - return; - } - - if (!selected.delete(file["_id"])) selected.add(file["_id"]); - } - }, - tds - ) - ); - } - - if (!rows.length) - rows.push(m("tr.empty", m("td", { colspan: labels.length }, "No files"))); - - const footerElements = []; - if (total != null) footerElements.push(`${files.length}/${total}`); - else footerElements.push(`${files.length}`); - - footerElements.push( - m( - "button", - { - title: "Show more files", - onclick: showMoreCallback, - disabled: files.length >= total || !filesResponse.fulfilled - }, - "More" - ) - ); - - if (downloadUrl) { - footerElements.push( - m("a.download-csv", { href: downloadUrl, download: "" }, "Download") - ); - } - - const tfoot = m( - "tfoot", - m("tr", m("td", { colspan: labels.length }, footerElements)) - ); - - const buttons = [ - m( - "button.primary", - { - title: "Delete selected files", - disabled: !selected.size, - onclick: e => { - e.redraw = false; - e.target.disabled = true; - Promise.all( - Array.from(selected).map(id => store.deleteResource("files", id)) - ) - .then(res => { - notifications.push("success", `${res.length} files deleted`); - store.fulfill(0, Date.now()); - }) - .catch(err => { - notifications.push("error", err.message); - store.fulfill(0, Date.now()); - }); - } - }, - "Delete" - ) - ]; - - if (window.authorizer.hasAccess("files", 3)) { - buttons.push( - m( - "button.primary", - { - title: "Create new file", - onclick: () => { - const cb = (): Children => { - return m( - putFormComponent, - Object.assign( - { - actionHandler: (action, object) => { - return new Promise(resolve => { - putActionHandler(action, object) - .then(errors => { - const errorList = errors - ? Object.values(errors) - : []; - if (errorList.length) { - for (const err of errorList) - notifications.push("error", err); - } else { - overlay.close(cb); - } - resolve(); - }) - .catch(err => { - notifications.push("error", err.message); - resolve(); - }); - }); - } - }, - formData - ) - ); - }; - overlay.open(cb); - } - }, - "New" - ) - ); - } - - return [ - m( - "table.table.highlight", - m("thead", m("tr", labels)), - m("tbody", rows), - tfoot - ), - m("div.actions-bar", buttons) - ]; -} - export const component: ClosureComponent = (): Component => { return { view: vnode => { @@ -345,15 +147,27 @@ export const component: ClosureComponent = (): Component => { m.route.set(m.route.get(), ops); } - function onSortChange(sort): void { - const ops = { sort }; + const sort = vnode.attrs["sort"] + ? memoizedJsonParse(vnode.attrs["sort"]) + : {}; + + const sortAttributes = {}; + for (let i = 0; i < attributes.length; i++) + sortAttributes[i] = sort[attributes[i].id] || 0; + + function onSortChange(sortAttrs): void { + const _sort = Object.assign({}, sort); + for (const [index, direction] of Object.entries(sortAttrs)) { + // Changing the priority of columns + delete _sort[attributes[index].id]; + _sort[attributes[index].id] = direction; + } + + const ops = { sort: JSON.stringify(_sort) }; if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"]; m.route.set(m.route.get(), ops); } - const sort = vnode.attrs["sort"] - ? memoizedJsonParse(vnode.attrs["sort"]) - : {}; let filter = vnode.attrs["filter"] ? memoizedParse(vnode.attrs["filter"]) : true; @@ -366,34 +180,107 @@ export const component: ClosureComponent = (): Component => { const count = store.count("files", filter); - const selected = new Set(); - if (vnode.state["selected"]) { - for (const file of files.value) { - if (vnode.state["selected"].has(file["_id"])) - selected.add(file["_id"]); - } - } - vnode.state["selected"] = selected; - const downloadUrl = getDownloadUrl(filter); const attrs = {}; - attrs["resource"] = "files"; - attrs["filter"] = vnode.attrs["filter"]; - attrs["onChange"] = onFilterChanged; + attrs["attributes"] = attributes; + attrs["data"] = files.value; + attrs["total"] = count.value; + attrs["showMoreCallback"] = showMore; + attrs["sortAttributes"] = sortAttributes; + attrs["onSortChange"] = onSortChange; + attrs["downloadUrl"] = downloadUrl; + + if (window.authorizer.hasAccess("files", 3)) { + attrs["actionsCallback"] = (selected): Children => { + return [ + m( + "button.primary", + { + title: "Create new file", + onclick: () => { + const cb = (): Children => { + return m( + putFormComponent, + Object.assign( + { + actionHandler: (action, object) => { + return new Promise(resolve => { + putActionHandler(action, object) + .then(errors => { + const errorList = errors + ? Object.values(errors) + : []; + if (errorList.length) { + for (const err of errorList) + notifications.push("error", err); + } else { + overlay.close(cb); + } + resolve(); + }) + .catch(err => { + notifications.push("error", err.message); + resolve(); + }); + }); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "New" + ), + m( + "button.primary", + { + title: "Delete selected files", + disabled: !selected.size, + onclick: e => { + if ( + !confirm(`Deleting ${selected.size} files. Are you sure?`) + ) + return; + + e.redraw = false; + e.target.disabled = true; + Promise.all( + Array.from(selected).map(id => + store.deleteResource("files", id) + ) + ) + .then(res => { + notifications.push( + "success", + `${res.length} files deleted` + ); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + store.fulfill(0, Date.now()); + }); + } + }, + "Delete" + ) + ]; + }; + } + + const filterAttrs = {}; + filterAttrs["resource"] = "files"; + filterAttrs["filter"] = vnode.attrs["filter"]; + filterAttrs["onChange"] = onFilterChanged; return [ m("h1", "Listing files"), - m(filterComponent, attrs), - renderTable( - files, - count.value, - selected, - showMore, - downloadUrl, - sort, - onSortChange - ) + m(filterComponent, filterAttrs), + m(indexTableComponent, attrs) ]; } }; diff --git a/ui/index-table-component.ts b/ui/index-table-component.ts new file mode 100644 index 0000000..cc47ce0 --- /dev/null +++ b/ui/index-table-component.ts @@ -0,0 +1,226 @@ +import { ClosureComponent, Component, Children } from "mithril"; +import { m } from "./components"; + +interface Attribute { + id?: string; + label: string; + type?: string; +} + +function renderTable( + attributes: Attribute[], + data: Record[], + total: number, + showMoreCallback: () => void, + selected: Set, + sortAttributes: Record, + onSortChange: (obj: Record) => void, + downloadUrl?: string, + valueCallback?: (attr: Attribute, record: Record) => Children, + actionsCallback?: Children | ((selected: Set) => Children), + recordActionsCallback?: Children | ((record: Record) => Children) +): Children { + const records = data || []; + const selectAll = m("input", { + type: "checkbox", + checked: records.length && selected.size === records.length, + onchange: e => { + for (const record of records) { + const id = record["_id"] || record["DeviceID.ID"].value[0]; + if (e.target.checked) selected.add(id); + else selected.delete(id); + } + }, + disabled: !total + }); + + // Table header + const labels = [m("th", selectAll)]; + for (let i = 0; i < attributes.length; i++) { + const attr = attributes[i]; + const label = attr.label; + if (!sortAttributes.hasOwnProperty(i)) { + labels.push(m("th", label)); + continue; + } + + let direction = 1; + + let symbol = "\u2981"; + if (sortAttributes[i] > 0) symbol = "\u2bc6"; + else if (sortAttributes[i] < 0) symbol = "\u2bc5"; + + const sortable = m( + "button", + { + onclick: () => { + if (sortAttributes[i] > 0) direction *= -1; + return onSortChange({ [i]: direction }); + } + }, + symbol + ); + + labels.push(m("th", [label, sortable])); + } + + // Table rows + const rows = []; + for (const record of records) { + const id = record["_id"] || record["DeviceID.ID"].value[0]; + const checkbox = m("input", { + type: "checkbox", + checked: selected.has(id), + onchange: e => { + if (e.target.checked) selected.add(id); + else selected.delete(id); + }, + onclick: e => { + e.stopPropagation(); + e.redraw = false; + } + }); + + const tds = [m("td", checkbox)]; + for (const attr of attributes) { + const attrs = {}; + let valueComponent; + + if (typeof valueCallback === "function") { + valueComponent = valueCallback(attr, record); + } else if (attr.type === "code") { + const firstLines = record[attr.id].split("\n", 11); + if (firstLines.length > 10) firstLines[10] = ["\ufe19"]; + if (attrs["title"] == null) attrs["title"] = firstLines.join("\n"); + valueComponent = firstLines[0] || ""; + } else { + valueComponent = record[attr.id]; + } + // TODO automatically add long text component on long values + + tds.push(m("td", attrs, valueComponent)); + } + + let recordButtons: Children = []; + if (typeof recordActionsCallback === "function") { + recordButtons = recordActionsCallback(record); + if (!Array.isArray(recordButtons)) recordButtons = [recordButtons]; + } else if (Array.isArray(recordActionsCallback)) { + recordButtons = recordActionsCallback; + } + + for (const button of recordButtons) + tds.push(m("td.table-row-links", button)); + + rows.push( + m( + "tr", + { + onclick: e => { + if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { + e.redraw = false; + return; + } + + if (!selected.delete(id)) selected.add(id); + } + }, + tds + ) + ); + } + + if (!rows.length) + rows.push(m("tr.empty", m("td", { colspan: labels.length }, "No records"))); + + // Table footer + const footerElements = []; + if (total != null) footerElements.push(`${records.length}/${total}`); + else footerElements.push(`${records.length}`); + + footerElements.push( + m( + "button", + { + title: "Show more records", + onclick: showMoreCallback, + disabled: !data.length || records.length >= total + }, + "More" + ) + ); + + if (downloadUrl) { + footerElements.push( + m("a.download-csv", { href: downloadUrl, download: "" }, "Download") + ); + } + + const tfoot = m( + "tfoot", + m("tr", m("td", { colspan: labels.length }, footerElements)) + ); + + // Actions bar + let buttons: Children = []; + if (typeof actionsCallback === "function") { + buttons = actionsCallback(selected); + if (!Array.isArray(buttons)) buttons = [buttons]; + } else if (Array.isArray(actionsCallback)) { + buttons = actionsCallback; + } + + const children = [ + m( + "table.table.highlight", + m("thead", m("tr", labels)), + m("tbody", rows), + tfoot + ) + ]; + + if (buttons.length) children.push(m("div.actions-bar", buttons)); + return children; +} + +const component: ClosureComponent = (): Component => { + let selected = new Set(); + + return { + view: vnode => { + const attributes = vnode.attrs["attributes"]; + const data = vnode.attrs["data"]; + const valueCallback = vnode.attrs["valueCallback"]; + const total = vnode.attrs["total"]; + const showMoreCallback = vnode.attrs["showMoreCallback"]; + const sortAttributes = vnode.attrs["sortAttributes"]; + const onSortChange = vnode.attrs["onSortChange"]; + const downloadUrl = vnode.attrs["downloadUrl"]; + const actionsCallback = vnode.attrs["actionsCallback"]; + const recordActionsCallback = vnode.attrs["recordActionsCallback"]; + + const _selected = new Set(); + for (const record of data) { + const id = record["_id"] || record["DeviceID.ID"].value[0]; + if (selected.has(id)) _selected.add(id); + } + selected = _selected; + + return renderTable( + attributes, + data, + total, + showMoreCallback, + selected, + sortAttributes, + onSortChange, + downloadUrl, + valueCallback, + actionsCallback, + recordActionsCallback + ); + } + }; +}; + +export default component; diff --git a/ui/permissions-page.ts b/ui/permissions-page.ts index 529748e..4525bbc 100644 --- a/ui/permissions-page.ts +++ b/ui/permissions-page.ts @@ -7,6 +7,7 @@ import * as store from "./store"; import * as notifications from "./notifications"; import memoize from "../lib/common/memoize"; import putFormComponent from "./put-form-component"; +import indexTableComponent from "./index-table-component"; import * as overlay from "./overlay"; import * as smartQuery from "./smart-query"; import { map, parse, stringify } from "../lib/common/expression-parser"; @@ -35,14 +36,14 @@ const attributes = [ "virtualParameters" ] }, - { id: "filter", label: "Filter", unsortable: true }, + { id: "filter", label: "Filter" }, { id: "access", label: "Access", type: "combo", options: ["1: count", "2: read", "3: write"] }, - { id: "validate", label: "Validate", unsortable: true } + { id: "validate", label: "Validate" } ]; const unpackSmartQuery = memoize(query => { @@ -155,246 +156,6 @@ export function init(args): Promise<{}> { return Promise.resolve({ filter, sort }); } -function renderTable( - permissionsResponse, - total, - selected, - showMoreCallback, - downloadUrl, - sort, - onSortChange -): Children { - const permissions = permissionsResponse.value; - const selectAll = m("input", { - type: "checkbox", - checked: permissions.length && selected.size === permissions.length, - onchange: e => { - for (const permission of permissions) { - if (e.target.checked) selected.add(permission["_id"]); - else selected.delete(permission["_id"]); - } - }, - disabled: !total - }); - - const labels = [m("th", selectAll)]; - for (const attr of attributes) { - const label = attr.label; - - if (attr.unsortable) { - labels.push(m("th", label)); - continue; - } - - let direction = 1; - - let symbol = "\u2981"; - if (sort[attr.id] > 0) symbol = "\u2bc6"; - else if (sort[attr.id] < 0) symbol = "\u2bc5"; - - const sortable = m( - "button", - { - onclick: () => { - if (sort[attr.id] > 0) direction *= -1; - return onSortChange(JSON.stringify({ [attr.id]: direction })); - } - }, - symbol - ); - - labels.push(m("th", [label, sortable])); - } - - const rows = []; - for (const permission of permissions) { - const checkbox = m("input", { - type: "checkbox", - checked: selected.has(permission["_id"]), - onchange: e => { - if (e.target.checked) selected.add(permission["_id"]); - else selected.delete(permission["_id"]); - }, - onclick: e => { - e.stopPropagation(); - e.redraw = false; - } - }); - - const tds = [m("td", checkbox)]; - for (const attr of attributes) { - let val = permission[attr.id]; - if (attr.id === "access") { - if (val === 1) val = "1: count"; - else if (val === 2) val = "2: read"; - else if (val === 3) val = "3: write"; - } - tds.push(m("td", val)); - } - - tds.push( - m( - "td.table-row-links", - m( - "button", - { - title: "Delete permission", - onclick: () => { - if ( - !confirm(`Deleting ${permission._id} permission. Are you sure?`) - ) - return; - - putActionHandler("delete", permission).catch(err => { - throw err; - }); - } - }, - "✕" - ) - ) - ); - - rows.push( - m( - "tr", - { - onclick: e => { - if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { - e.redraw = false; - return; - } - - if (!selected.delete(permission["_id"])) - selected.add(permission["_id"]); - } - }, - tds - ) - ); - } - - if (!rows.length) { - rows.push( - m("tr.empty", m("td", { colspan: labels.length }, "No permissions")) - ); - } - - const footerElements = []; - if (total != null) footerElements.push(`${permissions.length}/${total}`); - else footerElements.push(`${permissions.length}`); - - footerElements.push( - m( - "button", - { - title: "Show more permissions", - onclick: showMoreCallback, - disabled: permissions.length >= total || !permissionsResponse.fulfilled - }, - "More" - ) - ); - - if (downloadUrl) { - footerElements.push( - m("a.download-csv", { href: downloadUrl, download: "" }, "Download") - ); - } - - const tfoot = m( - "tfoot", - m("tr", m("td", { colspan: labels.length }, footerElements)) - ); - - const buttons = [ - m( - "button.primary", - { - title: "Delete selected permissions", - disabled: !selected.size, - onclick: e => { - e.redraw = false; - e.target.disabled = true; - Promise.all( - Array.from(selected).map(id => - store.deleteResource("permissions", id) - ) - ) - .then(res => { - notifications.push( - "success", - `${res.length} permissions deleted` - ); - store.fulfill(0, Date.now()); - }) - .catch(err => { - notifications.push("error", err.message); - store.fulfill(0, Date.now()); - }); - } - }, - "Delete" - ) - ]; - - if (window.authorizer.hasAccess("permissions", 3)) { - buttons.push( - m( - "button.primary", - { - title: "Create new permission", - onclick: () => { - const cb = (): Children => { - return m( - putFormComponent, - Object.assign( - { - actionHandler: (action, object) => { - return new Promise(resolve => { - putActionHandler(action, object) - .then(errors => { - const errorList = errors - ? Object.values(errors) - : []; - if (errorList.length) { - for (const err of errorList) - notifications.push("error", err); - } else { - overlay.close(cb); - } - resolve(); - }) - .catch(err => { - notifications.push("error", err.message); - resolve(); - }); - }); - } - }, - formData - ) - ); - }; - overlay.open(cb); - } - }, - "New" - ) - ); - } - - return [ - m( - "table.table.highlight", - m("thead", m("tr", labels)), - m("tbody", rows), - tfoot - ), - m("div.actions-bar", buttons) - ]; -} - export const component: ClosureComponent = (): Component => { return { view: vnode => { @@ -412,15 +173,30 @@ export const component: ClosureComponent = (): Component => { m.route.set(m.route.get(), ops); } - function onSortChange(sort): void { - const ops = { sort }; + const sort = vnode.attrs["sort"] + ? memoizedJsonParse(vnode.attrs["sort"]) + : {}; + + const sortAttributes = {}; + for (let i = 0; i < attributes.length; i++) { + const attr = attributes[i]; + if (!(attr.id === "filter" || attr.id === "validate")) + sortAttributes[i] = sort[attr.id] || 0; + } + + function onSortChange(sortAttrs): void { + const _sort = Object.assign({}, sort); + for (const [index, direction] of Object.entries(sortAttrs)) { + // Changing the priority of columns + delete _sort[attributes[index].id]; + _sort[attributes[index].id] = direction; + } + + const ops = { sort: JSON.stringify(_sort) }; if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"]; m.route.set(m.route.get(), ops); } - const sort = vnode.attrs["sort"] - ? memoizedJsonParse(vnode.attrs["sort"]) - : {}; let filter = vnode.attrs["filter"] ? memoizedParse(vnode.attrs["filter"]) : true; @@ -433,34 +209,146 @@ export const component: ClosureComponent = (): Component => { const count = store.count("permissions", filter); - const selected = new Set(); - if (vnode.state["selected"]) { - for (const permission of permissions.value) { - if (vnode.state["selected"].has(permission["_id"])) - selected.add(permission["_id"]); - } - } - vnode.state["selected"] = selected; - const downloadUrl = getDownloadUrl(filter); + const valueCallback = (attr, permission): Children => { + if (attr.id === "access") { + const val = permission["access"]; + if (val === 1) return "1: count"; + else if (val === 2) return "2: read"; + else if (val === 3) return "3: write"; + return val; + } + + return permission[attr.id]; + }; + const attrs = {}; - attrs["resource"] = "permissions"; - attrs["filter"] = vnode.attrs["filter"]; - attrs["onChange"] = onFilterChanged; + attrs["attributes"] = attributes; + attrs["data"] = permissions.value; + attrs["total"] = count.value; + attrs["valueCallback"] = valueCallback; + attrs["showMoreCallback"] = showMore; + attrs["sortAttributes"] = sortAttributes; + attrs["onSortChange"] = onSortChange; + attrs["downloadUrl"] = downloadUrl; + + if (window.authorizer.hasAccess("permissions", 3)) { + attrs["recordActionsCallback"] = permission => { + return [ + m( + "button", + { + title: "Delete permission", + onclick: () => { + if ( + !confirm( + `Deleting ${permission._id} permission. Are you sure?` + ) + ) + return; + + putActionHandler("delete", permission).catch(err => { + notifications.push("error", err.message); + }); + } + }, + "✕" + ) + ]; + }; + + attrs["actionsCallback"] = (selected): Children => { + return [ + m( + "button.primary", + { + title: "Create new permission", + onclick: () => { + const cb = (): Children => { + return m( + putFormComponent, + Object.assign( + { + actionHandler: (action, object) => { + return new Promise(resolve => { + putActionHandler(action, object) + .then(errors => { + const errorList = errors + ? Object.values(errors) + : []; + if (errorList.length) { + for (const err of errorList) + notifications.push("error", err); + } else { + overlay.close(cb); + } + resolve(); + }) + .catch(err => { + notifications.push("error", err.message); + resolve(); + }); + }); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "New" + ), + m( + "button.primary", + { + title: "Delete selected permissions", + disabled: !selected.size, + onclick: e => { + if ( + !confirm( + `Deleting ${selected.size} permissions. Are you sure?` + ) + ) + return; + + e.redraw = false; + e.target.disabled = true; + Promise.all( + Array.from(selected).map(id => + store.deleteResource("permissions", id) + ) + ) + .then(res => { + notifications.push( + "success", + `${res.length} permissions deleted` + ); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + store.fulfill(0, Date.now()); + }); + } + }, + "Delete" + ) + ]; + }; + } + + const filterAttrs = {}; + filterAttrs["resource"] = "permissions"; + filterAttrs["filter"] = vnode.attrs["filter"]; + filterAttrs["onChange"] = onFilterChanged; return [ m("h1", "Listing permissions"), - m(filterComponent, attrs), - renderTable( - permissions, - count.value, - selected, - showMore, - downloadUrl, - sort, - onSortChange - ) + m(filterComponent, filterAttrs), + m(indexTableComponent, attrs) ]; } }; diff --git a/ui/presets-page.ts b/ui/presets-page.ts index d075616..2c28437 100644 --- a/ui/presets-page.ts +++ b/ui/presets-page.ts @@ -6,6 +6,7 @@ import * as overlay from "./overlay"; import * as store from "./store"; import * as notifications from "./notifications"; import putFormComponent from "./put-form-component"; +import indexTableComponent from "./index-table-component"; import memoize from "../lib/common/memoize"; import * as smartQuery from "./smart-query"; import { map, parse, stringify } from "../lib/common/expression-parser"; @@ -20,10 +21,10 @@ const attributes = [ { id: "channel", label: "Channel" }, { id: "weight", label: "Weight" }, { id: "schedule", label: "Schedule" }, - { id: "events", label: "Events", unsortable: true }, - { id: "precondition", label: "Precondition", unsortable: true }, - { id: "provision", label: "Provision", type: "combo", unsortable: true }, - { id: "provisionArgs", label: "Arguments", unsortable: true } + { id: "events", label: "Events" }, + { id: "precondition", label: "Precondition" }, + { id: "provision", label: "Provision", type: "combo" }, + { id: "provisionArgs", label: "Arguments" } ]; const unpackSmartQuery = memoize(query => { @@ -122,273 +123,6 @@ export function init(args): Promise<{}> { return Promise.resolve({ filter, sort }); } -function renderTable( - presetsResponse, - total, - selected, - showMoreCallback, - downloadUrl, - sort, - onSortChange -): Children { - const presets = presetsResponse.value; - const selectAll = m("input", { - type: "checkbox", - checked: presets.length && selected.size === presets.length, - onchange: e => { - for (const preset of presets) { - if (e.target.checked) selected.add(preset["_id"]); - else selected.delete(preset["_id"]); - } - }, - disabled: !total - }); - - const labels = [m("th", selectAll)]; - - for (const attr of attributes) { - const label = attr.label; - - if (attr.unsortable) { - labels.push(m("th", label)); - continue; - } - - let direction = 1; - - let symbol = "\u2981"; - if (sort[attr.id] > 0) symbol = "\u2bc6"; - else if (sort[attr.id] < 0) symbol = "\u2bc5"; - - const sortable = m( - "button", - { - onclick: () => { - if (sort[attr.id] > 0) direction *= -1; - return onSortChange(JSON.stringify({ [attr.id]: direction })); - } - }, - symbol - ); - - labels.push(m("th", [label, sortable])); - } - - const rows = []; - for (const preset of presets) { - const checkbox = m("input", { - type: "checkbox", - checked: selected.has(preset["_id"]), - onchange: e => { - if (e.target.checked) selected.add(preset["_id"]); - else selected.delete(preset["_id"]); - }, - onclick: e => { - e.stopPropagation(); - e.redraw = false; - } - }); - - let devicesUrl = "/#!/devices"; - if (preset["precondition"].length) { - devicesUrl += `?${m.buildQueryString({ - filter: preset["precondition"] - })}`; - } - - const tds = [m("td", checkbox)]; - for (const 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: () => { - const cb = (): Children => { - return m( - putFormComponent, - Object.assign( - { - base: preset, - actionHandler: (action, object) => { - return new Promise(resolve => { - putActionHandler(action, object, false) - .then(errors => { - const errorList = errors - ? Object.values(errors) - : []; - if (errorList.length) { - for (const err of errorList) - notifications.push("error", err); - } else { - overlay.close(cb); - } - resolve(); - }) - .catch(err => { - notifications.push("error", err.message); - resolve(); - }); - }); - } - }, - formData - ) - ); - }; - overlay.open(cb); - } - }, - "Show" - ) - ) - ); - - rows.push( - m( - "tr", - { - onclick: e => { - if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { - e.redraw = false; - return; - } - - if (!selected.delete(preset["_id"])) selected.add(preset["_id"]); - } - }, - tds - ) - ); - } - - if (!rows.length) - rows.push(m("tr.empty", m("td", { colspan: labels.length }, "No presets"))); - - const footerElements = []; - if (total != null) footerElements.push(`${presets.length}/${total}`); - else footerElements.push(`${presets.length}`); - - footerElements.push( - m( - "button", - { - title: "Show more presets", - onclick: showMoreCallback, - disabled: presets.length >= total || !presetsResponse.fulfilled - }, - "More" - ) - ); - - if (downloadUrl) { - footerElements.push( - m("a.download-csv", { href: downloadUrl, download: "" }, "Download") - ); - } - - const tfoot = m( - "tfoot", - m("tr", m("td", { colspan: labels.length }, footerElements)) - ); - - const buttons = [ - m( - "button.primary", - { - title: "Delete selected presets", - disabled: !selected.size, - onclick: e => { - e.redraw = false; - e.target.disabled = true; - Promise.all( - Array.from(selected).map(id => store.deleteResource("presets", id)) - ) - .then(res => { - notifications.push("success", `${res.length} presets deleted`); - store.fulfill(0, Date.now()); - }) - .catch(err => { - notifications.push("error", err.message); - store.fulfill(0, Date.now()); - }); - } - }, - "Delete" - ) - ]; - - if (window.authorizer.hasAccess("presets", 3)) { - buttons.push( - m( - "button.primary", - { - title: "Create new preset", - onclick: () => { - const cb = (): Children => { - return m( - putFormComponent, - Object.assign( - { - actionHandler: (action, object) => { - return new Promise(resolve => { - putActionHandler(action, object, true) - .then(errors => { - const errorList = errors - ? Object.values(errors) - : []; - if (errorList.length) { - for (const err of errorList) - notifications.push("error", err); - } else { - overlay.close(cb); - } - resolve(); - }) - .catch(err => { - notifications.push("error", err.message); - resolve(); - }); - }); - } - }, - formData - ) - ); - }; - overlay.open(cb); - } - }, - "New" - ) - ); - } - - return [ - m( - "table.table.highlight", - m("thead", m("tr", labels)), - m("tbody", rows), - tfoot - ), - m("div.actions-bar", buttons) - ]; -} - export const component: ClosureComponent = (): Component => { return { view: vnode => { @@ -406,15 +140,37 @@ export const component: ClosureComponent = (): Component => { m.route.set(m.route.get(), ops); } - function onSortChange(sort): void { - const ops = { sort }; + const sort = vnode.attrs["sort"] + ? memoizedJsonParse(vnode.attrs["sort"]) + : {}; + + const sortAttributes = {}; + for (let i = 0; i < attributes.length; i++) { + const attr = attributes[i]; + if ( + !( + attr.id === "events" || + attr.id === "precondition" || + attr.id === "provision" || + attr.id === "provisionArgs" + ) + ) + sortAttributes[i] = sort[attr.id] || 0; + } + + function onSortChange(sortAttrs): void { + const _sort = Object.assign({}, sort); + for (const [index, direction] of Object.entries(sortAttrs)) { + // Changing the priority of columns + delete _sort[attributes[index].id]; + _sort[attributes[index].id] = direction; + } + + const ops = { sort: JSON.stringify(_sort) }; if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"]; m.route.set(m.route.get(), ops); } - const sort = vnode.attrs["sort"] - ? memoizedJsonParse(vnode.attrs["sort"]) - : {}; let filter = vnode.attrs["filter"] ? memoizedParse(vnode.attrs["filter"]) : true; @@ -447,34 +203,169 @@ export const component: ClosureComponent = (): Component => { provisionAttr["options"] = Array.from(provisionIds); } - const selected = new Set(); - if (vnode.state["selected"]) { - for (const preset of presets.value) { - if (vnode.state["selected"].has(preset["_id"])) - selected.add(preset["_id"]); - } - } - vnode.state["selected"] = selected; - const downloadUrl = getDownloadUrl(filter); + const valueCallback = (attr, preset): {} => { + if (attr.id !== "precondition") return preset[attr.id]; + let devicesUrl = "/#!/devices"; + if (preset["precondition"].length) { + devicesUrl += `?${m.buildQueryString({ + filter: preset["precondition"] + })}`; + } + + return m( + "a", + { href: devicesUrl, title: preset["precondition"] }, + preset["precondition"] + ); + }; + const attrs = {}; - attrs["resource"] = "presets"; - attrs["filter"] = vnode.attrs["filter"]; - attrs["onChange"] = onFilterChanged; + attrs["attributes"] = attributes; + attrs["data"] = presets.value; + attrs["total"] = count.value; + attrs["showMoreCallback"] = showMore; + attrs["sortAttributes"] = sortAttributes; + attrs["onSortChange"] = onSortChange; + attrs["downloadUrl"] = downloadUrl; + attrs["valueCallback"] = valueCallback; + attrs["recordActionsCallback"] = preset => { + return [ + m( + "a", + { + onclick: () => { + const cb = (): Children => { + return m( + putFormComponent, + Object.assign( + { + base: preset, + actionHandler: (action, object) => { + return new Promise(resolve => { + putActionHandler(action, object, false) + .then(errors => { + const errorList = errors + ? Object.values(errors) + : []; + if (errorList.length) { + for (const err of errorList) + notifications.push("error", err); + } else { + overlay.close(cb); + } + resolve(); + }) + .catch(err => { + notifications.push("error", err.message); + resolve(); + }); + }); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "Show" + ) + ]; + }; + + if (window.authorizer.hasAccess("presets", 3)) { + attrs["actionsCallback"] = (selected): Children => { + return [ + m( + "button.primary", + { + title: "Create new preset", + onclick: () => { + const cb = (): Children => { + return m( + putFormComponent, + Object.assign( + { + actionHandler: (action, object) => { + return new Promise(resolve => { + putActionHandler(action, object, true) + .then(errors => { + const errorList = errors + ? Object.values(errors) + : []; + if (errorList.length) { + for (const err of errorList) + notifications.push("error", err); + } else { + overlay.close(cb); + } + resolve(); + }) + .catch(err => { + notifications.push("error", err.message); + resolve(); + }); + }); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "New" + ), + m( + "button.primary", + { + title: "Delete selected presets", + disabled: !selected.size, + onclick: e => { + if ( + !confirm(`Deleting ${selected.size} presets. Are you sure?`) + ) + return; + + e.redraw = false; + e.target.disabled = true; + Promise.all( + Array.from(selected).map(id => + store.deleteResource("presets", id) + ) + ) + .then(res => { + notifications.push( + "success", + `${res.length} presets deleted` + ); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + store.fulfill(0, Date.now()); + }); + } + }, + "Delete" + ) + ]; + }; + } + + const filterAttrs = {}; + filterAttrs["resource"] = "presets"; + filterAttrs["filter"] = vnode.attrs["filter"]; + filterAttrs["onChange"] = onFilterChanged; return [ m("h1", "Listing presets"), - m(filterComponent, attrs), - renderTable( - presets, - count.value, - selected, - showMore, - downloadUrl, - sort, - onSortChange - ) + m(filterComponent, filterAttrs), + m(indexTableComponent, attrs) ]; } }; diff --git a/ui/provisions-page.ts b/ui/provisions-page.ts index b585591..e7081a1 100644 --- a/ui/provisions-page.ts +++ b/ui/provisions-page.ts @@ -6,6 +6,7 @@ import * as store from "./store"; import * as notifications from "./notifications"; import memoize from "../lib/common/memoize"; import putFormComponent from "./put-form-component"; +import indexTableComponent from "./index-table-component"; import * as overlay from "./overlay"; import * as smartQuery from "./smart-query"; import { map, parse, stringify } from "../lib/common/expression-parser"; @@ -119,264 +120,6 @@ export function init(args): Promise<{}> { }); } -function renderTable( - provisionsResponse, - total, - selected, - showMoreCallback, - downloadUrl, - sort, - onSortChange -): Children { - const provisions = provisionsResponse.value; - const selectAll = m("input", { - type: "checkbox", - checked: provisions.length && selected.size === provisions.length, - onchange: e => { - for (const provision of provisions) { - if (e.target.checked) selected.add(provision["_id"]); - else selected.delete(provision["_id"]); - } - }, - disabled: !total - }); - - const labels = [m("th", selectAll)]; - for (const attr of attributes) { - const label = attr.label; - - let direction = 1; - - let symbol = "\u2981"; - if (sort[attr.id] > 0) symbol = "\u2bc6"; - else if (sort[attr.id] < 0) symbol = "\u2bc5"; - - const sortable = m( - "button", - { - onclick: () => { - if (sort[attr.id] > 0) direction *= -1; - return onSortChange(JSON.stringify({ [attr.id]: direction })); - } - }, - symbol - ); - - labels.push(m("th", [label, sortable])); - } - - const rows = []; - for (const provision of provisions) { - const checkbox = m("input", { - type: "checkbox", - checked: selected.has(provision["_id"]), - onchange: e => { - if (e.target.checked) selected.add(provision["_id"]); - else selected.delete(provision["_id"]); - }, - onclick: e => { - e.stopPropagation(); - e.redraw = false; - } - }); - - const tds = [m("td", checkbox)]; - for (const attr of attributes) { - if (attr.id === "script") { - const firstLines = provision[attr.id].split("\n", 11); - if (firstLines.length > 10) firstLines[10] = ["\ufe19"]; - tds.push( - m("td", { title: firstLines.join("\n") }, firstLines[0] || "") - ); - } else { - tds.push(m("td", provision[attr.id])); - } - } - - tds.push( - m( - "td.table-row-links", - m( - "a", - { - onclick: () => { - const cb = (): Children => { - return m( - putFormComponent, - Object.assign( - { - base: provision, - actionHandler: (action, object) => { - return new Promise(resolve => { - putActionHandler(action, object, false) - .then(errors => { - const errorList = errors - ? Object.values(errors) - : []; - if (errorList.length) { - for (const err of errorList) - notifications.push("error", err); - } else { - overlay.close(cb); - } - resolve(); - }) - .catch(err => { - notifications.push("error", err.message); - resolve(); - }); - }); - } - }, - formData - ) - ); - }; - overlay.open(cb); - } - }, - "Show" - ) - ) - ); - - rows.push( - m( - "tr", - { - onclick: e => { - if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { - e.redraw = false; - return; - } - - if (!selected.delete(provision["_id"])) - selected.add(provision["_id"]); - } - }, - tds - ) - ); - } - - if (!rows.length) { - rows.push( - m("tr.empty", m("td", { colspan: labels.length }, "No provisions")) - ); - } - - const footerElements = []; - if (total != null) footerElements.push(`${provisions.length}/${total}`); - else footerElements.push(`${provisions.length}`); - - footerElements.push( - m( - "button", - { - title: "Show more provisions", - onclick: showMoreCallback, - disabled: provisions.length >= total || !provisionsResponse.fulfilled - }, - "More" - ) - ); - - if (downloadUrl) { - footerElements.push( - m("a.download-csv", { href: downloadUrl, download: "" }, "Download") - ); - } - - const tfoot = m( - "tfoot", - m("tr", m("td", { colspan: labels.length }, footerElements)) - ); - - const buttons = [ - m( - "button.primary", - { - title: "Delete selected provisions", - disabled: !selected.size, - onclick: e => { - e.redraw = false; - e.target.disabled = true; - Promise.all( - Array.from(selected).map(id => - store.deleteResource("provisions", id) - ) - ) - .then(res => { - notifications.push("success", `${res.length} provisions deleted`); - store.fulfill(0, Date.now()); - }) - .catch(err => { - notifications.push("error", err.message); - store.fulfill(0, Date.now()); - }); - } - }, - "Delete" - ) - ]; - - if (window.authorizer.hasAccess("provisions", 3)) { - buttons.push( - m( - "button.primary", - { - title: "Create new provision", - onclick: () => { - const cb = (): Children => { - return m( - putFormComponent, - Object.assign( - { - actionHandler: (action, object) => { - return new Promise(resolve => { - putActionHandler(action, object, true) - .then(errors => { - const errorList = errors - ? Object.values(errors) - : []; - if (errorList.length) { - for (const err of errorList) - notifications.push("error", err); - } else { - overlay.close(cb); - } - resolve(); - }) - .catch(err => { - notifications.push("error", err.message); - resolve(); - }); - }); - } - }, - formData - ) - ); - }; - overlay.open(cb); - } - }, - "New" - ) - ); - } - - return [ - m( - "table.table.highlight", - m("thead", m("tr", labels)), - m("tbody", rows), - tfoot - ), - m("div.actions-bar", buttons) - ]; -} - export const component: ClosureComponent = (): Component => { return { view: vnode => { @@ -394,15 +137,27 @@ export const component: ClosureComponent = (): Component => { m.route.set(m.route.get(), ops); } - function onSortChange(sort): void { - const ops = { sort }; + const sort = vnode.attrs["sort"] + ? memoizedJsonParse(vnode.attrs["sort"]) + : {}; + + const sortAttributes = {}; + for (let i = 0; i < attributes.length; i++) + sortAttributes[i] = sort[attributes[i].id] || 0; + + function onSortChange(sortAttrs): void { + const _sort = Object.assign({}, sort); + for (const [index, direction] of Object.entries(sortAttrs)) { + // Changing the priority of columns + delete _sort[attributes[index].id]; + _sort[attributes[index].id] = direction; + } + + const ops = { sort: JSON.stringify(_sort) }; if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"]; m.route.set(m.route.get(), ops); } - const sort = vnode.attrs["sort"] - ? memoizedJsonParse(vnode.attrs["sort"]) - : {}; let filter = vnode.attrs["filter"] ? memoizedParse(vnode.attrs["filter"]) : true; @@ -415,34 +170,154 @@ export const component: ClosureComponent = (): Component => { const count = store.count("provisions", filter); - const selected = new Set(); - if (vnode.state["selected"]) { - for (const provision of provisions.value) { - if (vnode.state["selected"].has(provision["_id"])) - selected.add(provision["_id"]); - } - } - vnode.state["selected"] = selected; - const downloadUrl = getDownloadUrl(filter); const attrs = {}; - attrs["resource"] = "provisions"; - attrs["filter"] = vnode.attrs["filter"]; - attrs["onChange"] = onFilterChanged; + attrs["attributes"] = attributes; + attrs["data"] = provisions.value; + attrs["total"] = count.value; + attrs["showMoreCallback"] = showMore; + attrs["sortAttributes"] = sortAttributes; + attrs["onSortChange"] = onSortChange; + attrs["downloadUrl"] = downloadUrl; + attrs["recordActionsCallback"] = provision => { + return [ + m( + "a", + { + onclick: () => { + const cb = (): Children => { + return m( + putFormComponent, + Object.assign( + { + base: provision, + actionHandler: (action, object) => { + return new Promise(resolve => { + putActionHandler(action, object, false) + .then(errors => { + const errorList = errors + ? Object.values(errors) + : []; + if (errorList.length) { + for (const err of errorList) + notifications.push("error", err); + } else { + overlay.close(cb); + } + resolve(); + }) + .catch(err => { + notifications.push("error", err.message); + resolve(); + }); + }); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "Show" + ) + ]; + }; + + if (window.authorizer.hasAccess("provisions", 3)) { + attrs["actionsCallback"] = (selected): Children => { + return [ + m( + "button.primary", + { + title: "Create new provision", + onclick: () => { + const cb = (): Children => { + return m( + putFormComponent, + Object.assign( + { + actionHandler: (action, object) => { + return new Promise(resolve => { + putActionHandler(action, object, true) + .then(errors => { + const errorList = errors + ? Object.values(errors) + : []; + if (errorList.length) { + for (const err of errorList) + notifications.push("error", err); + } else { + overlay.close(cb); + } + resolve(); + }) + .catch(err => { + notifications.push("error", err.message); + resolve(); + }); + }); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "New" + ), + m( + "button.primary", + { + title: "Delete selected provisions", + disabled: !selected.size, + onclick: e => { + if ( + !confirm( + `Deleting ${selected.size} provisions. Are you sure?` + ) + ) + return; + + e.redraw = false; + e.target.disabled = true; + Promise.all( + Array.from(selected).map(id => + store.deleteResource("provisions", id) + ) + ) + .then(res => { + notifications.push( + "success", + `${res.length} provisions deleted` + ); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + store.fulfill(0, Date.now()); + }); + } + }, + "Delete" + ) + ]; + }; + } + + const filterAttrs = {}; + filterAttrs["resource"] = "provisions"; + filterAttrs["filter"] = vnode.attrs["filter"]; + filterAttrs["onChange"] = onFilterChanged; return [ m("h1", "Listing provisions"), - m(filterComponent, attrs), - renderTable( - provisions, - count.value, - selected, - showMore, - downloadUrl, - sort, - onSortChange - ) + m(filterComponent, filterAttrs), + m(indexTableComponent, attrs) ]; } }; diff --git a/ui/users-page.ts b/ui/users-page.ts index b1a619d..6fa69bb 100644 --- a/ui/users-page.ts +++ b/ui/users-page.ts @@ -7,6 +7,7 @@ import * as store from "./store"; import * as notifications from "./notifications"; import memoize from "../lib/common/memoize"; import putFormComponent from "./put-form-component"; +import indexTableComponent from "./index-table-component"; import * as overlay from "./overlay"; import * as smartQuery from "./smart-query"; import { map, parse, stringify } from "../lib/common/expression-parser"; @@ -21,13 +22,7 @@ const memoizedJsonParse = memoize(JSON.parse); const attributes = [ { id: "_id", label: "Username" }, - { - id: "roles", - label: "Roles", - type: "multi", - options: [], - unsortable: true - } + { id: "roles", label: "Roles", type: "multi", options: [] } ]; const unpackSmartQuery = memoize(query => { @@ -144,287 +139,6 @@ export function init(args): Promise<{}> { return Promise.resolve({ filter, sort }); } -function renderTable( - usersResponse, - total, - selected, - showMoreCallback, - downloadUrl, - sort, - onSortChange -): Children { - const users = usersResponse.value; - const selectAll = m("input", { - type: "checkbox", - checked: users.length && selected.size === users.length, - onchange: e => { - for (const user of users) { - if (e.target.checked) selected.add(user["_id"]); - else selected.delete(user["_id"]); - } - }, - disabled: !total - }); - - const labels = [m("th", selectAll)]; - for (const attr of attributes) { - const label = attr.label; - - if (attr.unsortable) { - labels.push(m("th", label)); - continue; - } - - let direction = 1; - - let symbol = "\u2981"; - if (sort[attr.id] > 0) symbol = "\u2bc6"; - else if (sort[attr.id] < 0) symbol = "\u2bc5"; - - const sortable = m( - "button", - { - onclick: () => { - if (sort[attr.id] > 0) direction *= -1; - return onSortChange(JSON.stringify({ [attr.id]: direction })); - } - }, - symbol - ); - - labels.push(m("th", [label, sortable])); - } - - const canChangePasswords = window.authorizer.hasAccess("users", 3); - - const rows = []; - for (const user of users) { - const checkbox = m("input", { - type: "checkbox", - checked: selected.has(user["_id"]), - onchange: e => { - if (e.target.checked) selected.add(user["_id"]); - else selected.delete(user["_id"]); - }, - onclick: e => { - e.stopPropagation(); - e.redraw = false; - } - }); - - const tds = [m("td", checkbox)]; - for (const attr of attributes) tds.push(m("td", user[attr.id])); - - tds.push( - m( - "td.table-row-links", - m( - "a", - { - onclick: () => { - const cb = (): Children => { - const children = [ - m( - putFormComponent, - Object.assign( - { - base: { - _id: user._id, - roles: user.roles.split(",") - }, - actionHandler: (action, object) => { - return new Promise(resolve => { - putActionHandler(action, object, false) - .then(errors => { - const errorList = errors - ? Object.values(errors) - : []; - if (errorList.length) { - for (const err of errorList) - notifications.push("error", err); - } else { - overlay.close(cb); - } - resolve(); - }) - .catch(err => { - notifications.push("error", err.message); - resolve(); - }); - }); - } - }, - { - resource: "users", - attributes: attributes - } - ) - ) - ]; - - if (canChangePasswords) { - children.push(m("hr")); - const attrs = { - noAuth: true, - username: user._id, - onPasswordChange: () => { - overlay.close(cb); - m.redraw(); - } - }; - children.push(m(changePasswordComponent, attrs)); - } - - return children; - }; - overlay.open(cb); - } - }, - "Show" - ) - ) - ); - - rows.push( - m( - "tr", - { - onclick: e => { - if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { - e.redraw = false; - return; - } - - if (!selected.delete(user["_id"])) selected.add(user["_id"]); - } - }, - tds - ) - ); - } - - if (!rows.length) - rows.push(m("tr.empty", m("td", { colspan: labels.length }, "No users"))); - - const footerElements = []; - if (total != null) footerElements.push(`${users.length}/${total}`); - else footerElements.push(`${users.length}`); - - footerElements.push( - m( - "button", - { - title: "Show more users", - onclick: showMoreCallback, - disabled: users.length >= total || !usersResponse.fulfilled - }, - "More" - ) - ); - - if (downloadUrl) { - footerElements.push( - m("a.download-csv", { href: downloadUrl, download: "" }, "Download") - ); - } - - const tfoot = m( - "tfoot", - m("tr", m("td", { colspan: labels.length }, footerElements)) - ); - - const buttons = [ - m( - "button.primary", - { - title: "Delete selected users", - disabled: !selected.size, - onclick: e => { - e.redraw = false; - e.target.disabled = true; - Promise.all( - Array.from(selected).map(id => store.deleteResource("users", id)) - ) - .then(res => { - notifications.push("success", `${res.length} users deleted`); - store.fulfill(0, Date.now()); - }) - .catch(err => { - notifications.push("error", err.message); - store.fulfill(0, Date.now()); - }); - } - }, - "Delete" - ) - ]; - - if (window.authorizer.hasAccess("users", 3)) { - const formData = { - resource: "users", - attributes: [ - attributes[0], - { id: "password", label: "Password", type: "password" }, - { id: "confirm", label: "Confirm password", type: "password" }, - attributes[1] - ] - }; - buttons.push( - m( - "button.primary", - { - title: "Create new user", - onclick: () => { - const cb = (): Children => { - return m( - putFormComponent, - Object.assign( - { - actionHandler: (action, object) => { - return new Promise(resolve => { - putActionHandler(action, object, true) - .then(errors => { - const errorList = errors - ? Object.values(errors) - : []; - if (errorList.length) { - for (const err of errorList) - notifications.push("error", err); - } else { - overlay.close(cb); - } - resolve(); - }) - .catch(err => { - notifications.push("error", err.message); - resolve(); - }); - }); - } - }, - formData - ) - ); - }; - overlay.open(cb); - } - }, - "New" - ) - ); - } - - return [ - m( - "table.table.highlight", - m("thead", m("tr", labels)), - m("tbody", rows), - tfoot - ), - m("div.actions-bar", buttons) - ]; -} - export const component: ClosureComponent = (): Component => { return { view: vnode => { @@ -442,15 +156,30 @@ export const component: ClosureComponent = (): Component => { m.route.set(m.route.get(), ops); } - function onSortChange(sort): void { - const ops = { sort }; + const sort = vnode.attrs["sort"] + ? memoizedJsonParse(vnode.attrs["sort"]) + : {}; + + const sortAttributes = {}; + for (let i = 0; i < attributes.length; i++) { + const attr = attributes[i]; + if (attr.id !== "roles") + sortAttributes[i] = sort[attributes[i].id] || 0; + } + + function onSortChange(sortAttrs): void { + const _sort = Object.assign({}, sort); + for (const [index, direction] of Object.entries(sortAttrs)) { + // Changing the priority of columns + delete _sort[attributes[index].id]; + _sort[attributes[index].id] = direction; + } + + const ops = { sort: JSON.stringify(_sort) }; if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"]; m.route.set(m.route.get(), ops); } - const sort = vnode.attrs["sort"] - ? memoizedJsonParse(vnode.attrs["sort"]) - : {}; let filter = vnode.attrs["filter"] ? memoizedParse(vnode.attrs["filter"]) : true; @@ -472,34 +201,186 @@ export const component: ClosureComponent = (): Component => { } } - const selected = new Set(); - if (vnode.state["selected"]) { - for (const user of users.value) { - if (vnode.state["selected"].has(user["_id"])) - selected.add(user["_id"]); - } - } - vnode.state["selected"] = selected; - const downloadUrl = getDownloadUrl(filter); + const canWrite = window.authorizer.hasAccess("users", 3); + const attrs = {}; - attrs["resource"] = "users"; - attrs["filter"] = vnode.attrs["filter"]; - attrs["onChange"] = onFilterChanged; + attrs["attributes"] = attributes; + attrs["data"] = users.value; + attrs["total"] = count.value; + attrs["showMoreCallback"] = showMore; + attrs["sortAttributes"] = sortAttributes; + attrs["onSortChange"] = onSortChange; + attrs["downloadUrl"] = downloadUrl; + attrs["recordActionsCallback"] = user => { + return [ + m( + "a", + { + onclick: () => { + const cb = (): Children => { + const children = [ + m( + putFormComponent, + Object.assign( + { + base: { + _id: user._id, + roles: user.roles.split(",") + }, + actionHandler: (action, object) => { + return new Promise(resolve => { + putActionHandler(action, object, false) + .then(errors => { + const errorList = errors + ? Object.values(errors) + : []; + if (errorList.length) { + for (const err of errorList) + notifications.push("error", err); + } else { + overlay.close(cb); + } + resolve(); + }) + .catch(err => { + notifications.push("error", err.message); + resolve(); + }); + }); + } + }, + { + resource: "users", + attributes: attributes + } + ) + ) + ]; + + if (canWrite) { + children.push(m("hr")); + const _attrs = { + noAuth: true, + username: user._id, + onPasswordChange: () => { + overlay.close(cb); + m.redraw(); + } + }; + children.push(m(changePasswordComponent, _attrs)); + } + + return children; + }; + overlay.open(cb); + } + }, + "Show" + ) + ]; + }; + + if (canWrite) { + const formData = { + resource: "users", + attributes: [ + attributes[0], + { id: "password", label: "Password", type: "password" }, + { id: "confirm", label: "Confirm password", type: "password" }, + attributes[1] + ] + }; + attrs["actionsCallback"] = (selected): Children => { + return [ + m( + "button.primary", + { + title: "Create new user", + onclick: () => { + const cb = (): Children => { + return m( + putFormComponent, + Object.assign( + { + actionHandler: (action, object) => { + return new Promise(resolve => { + putActionHandler(action, object, true) + .then(errors => { + const errorList = errors + ? Object.values(errors) + : []; + if (errorList.length) { + for (const err of errorList) + notifications.push("error", err); + } else { + overlay.close(cb); + } + resolve(); + }) + .catch(err => { + notifications.push("error", err.message); + resolve(); + }); + }); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "New" + ), + m( + "button.primary", + { + title: "Delete selected users", + disabled: !selected.size, + onclick: e => { + if ( + !confirm(`Deleting ${selected.size} users. Are you sure?`) + ) + return; + + e.redraw = false; + e.target.disabled = true; + Promise.all( + Array.from(selected).map(id => + store.deleteResource("users", id) + ) + ) + .then(res => { + notifications.push( + "success", + `${res.length} users deleted` + ); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + store.fulfill(0, Date.now()); + }); + } + }, + "Delete" + ) + ]; + }; + } + + const filterAttrs = {}; + filterAttrs["resource"] = "users"; + filterAttrs["filter"] = vnode.attrs["filter"]; + filterAttrs["onChange"] = onFilterChanged; return [ m("h1", "Listing users"), - m(filterComponent, attrs), - renderTable( - users, - count.value, - selected, - showMore, - downloadUrl, - sort, - onSortChange - ) + m(filterComponent, filterAttrs), + m(indexTableComponent, attrs) ]; } }; diff --git a/ui/virtual-parameters-page.ts b/ui/virtual-parameters-page.ts index 41dd3dd..6dbc72e 100644 --- a/ui/virtual-parameters-page.ts +++ b/ui/virtual-parameters-page.ts @@ -6,6 +6,7 @@ import * as store from "./store"; import * as notifications from "./notifications"; import memoize from "../lib/common/memoize"; import putFormComponent from "./put-form-component"; +import indexTableComponent from "./index-table-component"; import * as overlay from "./overlay"; import * as smartQuery from "./smart-query"; import { map, parse, stringify } from "../lib/common/expression-parser"; @@ -119,274 +120,6 @@ export function init(args): Promise<{}> { }); } -function renderTable( - virtualParametersResponse, - total, - selected, - showMoreCallback, - downloadUrl, - sort, - onSortChange -): Children { - const virtualParameters = virtualParametersResponse.value; - const selectAll = m("input", { - type: "checkbox", - checked: - virtualParameters.length && selected.size === virtualParameters.length, - onchange: e => { - for (const virtualParameter of virtualParameters) { - if (e.target.checked) selected.add(virtualParameter["_id"]); - else selected.delete(virtualParameter["_id"]); - } - }, - disabled: !total - }); - - const labels = [m("th", selectAll)]; - for (const attr of attributes) { - const label = attr.label; - - let direction = 1; - - let symbol = "\u2981"; - if (sort[attr.id] > 0) symbol = "\u2bc6"; - else if (sort[attr.id] < 0) symbol = "\u2bc5"; - - const sortable = m( - "button", - { - onclick: () => { - if (sort[attr.id] > 0) direction *= -1; - return onSortChange(JSON.stringify({ [attr.id]: direction })); - } - }, - symbol - ); - - labels.push(m("th", [label, sortable])); - } - - const rows = []; - for (const virtualParameter of virtualParameters) { - const checkbox = m("input", { - type: "checkbox", - checked: selected.has(virtualParameter["_id"]), - onchange: e => { - if (e.target.checked) selected.add(virtualParameter["_id"]); - else selected.delete(virtualParameter["_id"]); - }, - onclick: e => { - e.stopPropagation(); - e.redraw = false; - } - }); - - const tds = [m("td", checkbox)]; - for (const attr of attributes) { - if (attr.id === "script") { - const firstLines = virtualParameter[attr.id].split("\n", 11); - if (firstLines.length > 10) firstLines[10] = ["\ufe19"]; - tds.push( - m("td", { title: firstLines.join("\n") }, firstLines[0] || "") - ); - } else { - tds.push(m("td", virtualParameter[attr.id])); - } - } - - tds.push( - m( - "td.table-row-links", - m( - "a", - { - onclick: () => { - const cb = (): Children => { - return m( - putFormComponent, - Object.assign( - { - base: virtualParameter, - actionHandler: (action, object) => { - return new Promise(resolve => { - putActionHandler(action, object, false) - .then(errors => { - const errorList = errors - ? Object.values(errors) - : []; - if (errorList.length) { - for (const err of errorList) - notifications.push("error", err); - } else { - overlay.close(cb); - } - resolve(); - }) - .catch(err => { - notifications.push("error", err.message); - resolve(); - }); - }); - } - }, - formData - ) - ); - }; - overlay.open(cb); - } - }, - "Show" - ) - ) - ); - - rows.push( - m( - "tr", - { - onclick: e => { - if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { - e.redraw = false; - return; - } - - if (!selected.delete(virtualParameter["_id"])) - selected.add(virtualParameter["_id"]); - } - }, - tds - ) - ); - } - - if (!rows.length) { - rows.push( - m( - "tr.empty", - m("td", { colspan: labels.length }, "No virtual parameters") - ) - ); - } - - const footerElements = []; - if (total != null) - footerElements.push(`${virtualParameters.length}/${total}`); - else footerElements.push(`${virtualParameters.length}`); - - footerElements.push( - m( - "button", - { - title: "Show more virtual parameters", - onclick: showMoreCallback, - disabled: - virtualParameters.length >= total || - !virtualParametersResponse.fulfilled - }, - "More" - ) - ); - - if (downloadUrl) { - footerElements.push( - m("a.download-csv", { href: downloadUrl, download: "" }, "Download") - ); - } - - const tfoot = m( - "tfoot", - m("tr", m("td", { colspan: labels.length }, footerElements)) - ); - - const buttons = [ - m( - "button.primary", - { - title: "Delete selected virtual parameters", - disabled: !selected.size, - onclick: e => { - e.redraw = false; - e.target.disabled = true; - Promise.all( - Array.from(selected).map(id => - store.deleteResource("virtualParameters", id) - ) - ) - .then(res => { - notifications.push( - "success", - `${res.length} virtual parameters deleted` - ); - store.fulfill(0, Date.now()); - }) - .catch(err => { - notifications.push("error", err.message); - store.fulfill(0, Date.now()); - }); - } - }, - "Delete" - ) - ]; - - if (window.authorizer.hasAccess("virtualParameters", 3)) { - buttons.push( - m( - "button.primary", - { - title: "Create new virtual parameter", - onclick: () => { - const cb = (): Children => { - return m( - putFormComponent, - Object.assign( - { - actionHandler: (action, object) => { - return new Promise(resolve => { - putActionHandler(action, object, true) - .then(errors => { - const errorList = errors - ? Object.values(errors) - : []; - if (errorList.length) { - for (const err of errorList) - notifications.push("error", err); - } else { - overlay.close(cb); - } - resolve(); - }) - .catch(err => { - notifications.push("error", err.message); - resolve(); - }); - }); - } - }, - formData - ) - ); - }; - overlay.open(cb); - } - }, - "New" - ) - ); - } - - return [ - m( - "table.table.highlight", - m("thead", m("tr", labels)), - m("tbody", rows), - tfoot - ), - m("div.actions-bar", buttons) - ]; -} - export const component: ClosureComponent = (): Component => { return { view: vnode => { @@ -404,15 +137,27 @@ export const component: ClosureComponent = (): Component => { m.route.set(m.route.get(), ops); } - function onSortChange(sort): void { - const ops = { sort }; + const sort = vnode.attrs["sort"] + ? memoizedJsonParse(vnode.attrs["sort"]) + : {}; + + const sortAttributes = {}; + for (let i = 0; i < attributes.length; i++) + sortAttributes[i] = sort[attributes[i].id] || 0; + + function onSortChange(sortAttrs): void { + const _sort = Object.assign({}, sort); + for (const [index, direction] of Object.entries(sortAttrs)) { + // Changing the priority of columns + delete _sort[attributes[index].id]; + _sort[attributes[index].id] = direction; + } + + const ops = { sort: JSON.stringify(_sort) }; if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"]; m.route.set(m.route.get(), ops); } - const sort = vnode.attrs["sort"] - ? memoizedJsonParse(vnode.attrs["sort"]) - : {}; let filter = vnode.attrs["filter"] ? memoizedParse(vnode.attrs["filter"]) : true; @@ -425,34 +170,156 @@ export const component: ClosureComponent = (): Component => { const count = store.count("virtualParameters", filter); - const selected = new Set(); - if (vnode.state["selected"]) { - for (const virtualParameter of virtualParameters.value) { - if (vnode.state["selected"].has(virtualParameter["_id"])) - selected.add(virtualParameter["_id"]); - } - } - vnode.state["selected"] = selected; - const downloadUrl = getDownloadUrl(filter); const attrs = {}; - attrs["resource"] = "virtualParameters"; - attrs["filter"] = vnode.attrs["filter"]; - attrs["onChange"] = onFilterChanged; + attrs["attributes"] = attributes; + attrs["data"] = virtualParameters.value; + attrs["total"] = count.value; + attrs["showMoreCallback"] = showMore; + attrs["sortAttributes"] = sortAttributes; + attrs["onSortChange"] = onSortChange; + attrs["downloadUrl"] = downloadUrl; + attrs["recordActionsCallback"] = virtualParameter => { + return [ + m( + "a", + { + onclick: () => { + const cb = (): Children => { + return m( + putFormComponent, + Object.assign( + { + base: virtualParameter, + actionHandler: (action, object) => { + return new Promise(resolve => { + putActionHandler(action, object, false) + .then(errors => { + const errorList = errors + ? Object.values(errors) + : []; + if (errorList.length) { + for (const err of errorList) + notifications.push("error", err); + } else { + overlay.close(cb); + } + resolve(); + }) + .catch(err => { + notifications.push("error", err.message); + resolve(); + }); + }); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "Show" + ) + ]; + }; + + if (window.authorizer.hasAccess("virtualParameters", 3)) { + attrs["actionsCallback"] = (selected): Children => { + return [ + m( + "button.primary", + { + title: "Create new virtual parameter", + onclick: () => { + const cb = (): Children => { + return m( + putFormComponent, + Object.assign( + { + actionHandler: (action, object) => { + return new Promise(resolve => { + putActionHandler(action, object, true) + .then(errors => { + const errorList = errors + ? Object.values(errors) + : []; + if (errorList.length) { + for (const err of errorList) + notifications.push("error", err); + } else { + overlay.close(cb); + } + resolve(); + }) + .catch(err => { + notifications.push("error", err.message); + resolve(); + }); + }); + } + }, + formData + ) + ); + }; + overlay.open(cb); + } + }, + "New" + ), + m( + "button.primary", + { + title: "Delete selected virtual parameters", + disabled: !selected.size, + onclick: e => { + if ( + !confirm( + `Deleting ${ + selected.size + } virtual parameters. Are you sure?` + ) + ) + return; + + e.redraw = false; + e.target.disabled = true; + Promise.all( + Array.from(selected).map(id => + store.deleteResource("virtualParameters", id) + ) + ) + .then(res => { + notifications.push( + "success", + `${res.length} virtual parameters deleted` + ); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + store.fulfill(0, Date.now()); + }); + } + }, + "Delete" + ) + ]; + }; + } + + const filterAttrs = {}; + filterAttrs["resource"] = "virtualParameters"; + filterAttrs["filter"] = vnode.attrs["filter"]; + filterAttrs["onChange"] = onFilterChanged; return [ m("h1", "Listing virtual parameters"), - m(filterComponent, attrs), - renderTable( - virtualParameters, - count.value, - selected, - showMore, - downloadUrl, - sort, - onSortChange - ) + m(filterComponent, filterAttrs), + m(indexTableComponent, attrs) ]; } };