aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/dashboard/dashboard.html
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/dashboard/dashboard.html')
-rw-r--r--src/api/dashboard/dashboard.html87
1 files changed, 77 insertions, 10 deletions
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
index 870b0df..5ab5ac8 100644
--- a/src/api/dashboard/dashboard.html
+++ b/src/api/dashboard/dashboard.html
@@ -85,6 +85,10 @@
max-height: 20em; overflow: auto; }
.note { color: var(--muted); font-style: italic; }
.error { color: var(--bad); }
+ .tree .add { color: var(--muted); cursor: pointer; }
+ .tree .add:hover { color: var(--fg); }
+ .tree .nested { padding-left: 1.1em; }
+ .editor input.extra[type=text] { width: 12em; margin-bottom: 2px; display: block; }
.editor { margin: 2px 0 4px; }
.tree details > .editor { margin-left: 1.1em; }
.editor input[type=text], .editor textarea { width: 100%; font: inherit; color: var(--fg);
@@ -600,7 +604,10 @@ const EMPTY_VALUE = { map: () => ({}), list: () => [], text: () => "" };
// saved with PUT /store/<path> (the whole value, objects too) ---
// A string is edited as text (sent as text/plain), anything else as JSON; "JSON"
// switches. `done(saved)` when saved or cancelled.
-function editor(path, value, done) {
+// `options` for adding rather than replacing: `extra`, a short field before the value
+// (`{ label, initial, hint }`: the key, or the index); `check(extra)`, an error text if
+// it won't do; `send(body, type, extra)`, the request (PUT to `path` without).
+function editor(path, value, done, options = {}) {
const message = el("span", { class: "note" });
const fail = text => { message.className = "error"; message.textContent = text; };
const asText = typeof value === "string";
@@ -623,10 +630,19 @@ function editor(path, value, done) {
fail("Only a JSON string can be edited as text.");
}
});
- const save = el("button", { type: "button" }, "Save");
+ const extra = options.extra
+ ? el("input", { type: "text", spellcheck: "false", class: "extra",
+ placeholder: options.extra.label, title: options.extra.hint || options.extra.label })
+ : null;
+ if (extra) extra.value = options.extra.initial || "";
+ const send = options.send || ((body, type) => fetch(storeUrl(path, false),
+ { method: "PUT", body, headers: { "Content-Type": type } }));
+ const save = el("button", { type: "button" }, options.extra ? "Add" : "Save");
const cancel = el("button", { type: "button" }, "Cancel");
const submit = async () => {
const body = field.value;
+ const problem = extra && options.check ? options.check(extra.value) : null;
+ if (problem) { fail(problem); return; }
if (json.checked) {
try { JSON.parse(body); } catch (error) {
fail("Not JSON: " + error.message); return;
@@ -636,10 +652,13 @@ function editor(path, value, done) {
message.className = "note";
message.textContent = "Saving…";
try {
- const response = await fetch(storeUrl(path, false), { method: "PUT", body,
- headers: { "Content-Type": json.checked ? "application/json" : "text/plain" } });
+ const response = await send(body, json.checked ? "application/json" : "text/plain",
+ extra ? extra.value : null);
if (!response.ok) {
- throw new Error("HTTP " + response.status + (response.status === 403 ? ", not allowed" : ""));
+ // The node says why (`{"error": …}`), where it does.
+ let why = response.status === 403 ? "not allowed" : "";
+ try { why = (await response.json()).error || why; } catch { /* no body */ }
+ throw new Error("HTTP " + response.status + (why ? ": " + why : ""));
}
done(true);
} catch (error) {
@@ -649,19 +668,53 @@ function editor(path, value, done) {
};
save.addEventListener("click", submit);
cancel.addEventListener("click", () => done(false));
- field.addEventListener("keydown", event => {
+ const keys = (input, line) => input.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); done(false); }
// Enter saves a line; Ctrl or ⌘ and Enter saves an area.
- else if (event.key === "Enter" && (!long || event.ctrlKey || event.metaKey)) {
+ else if (event.key === "Enter" && (line || event.ctrlKey || event.metaKey)) {
event.preventDefault(); submit();
}
});
- setTimeout(() => field.focus());
- return el("div", { class: "editor" }, field,
+ keys(field, !long);
+ if (extra) keys(extra, true);
+ setTimeout(() => (extra && !extra.value ? extra : field).focus());
+ return el("div", { class: "editor" }, ...(extra ? [extra] : []), field,
el("div", {}, save, " ", cancel, " ", el("label", {}, json, " JSON"), " ",
el("span", { class: "note" }, long ? "Ctrl+Enter saves, Esc cancels" : "Enter saves, Esc cancels"),
" ", message));
}
+// Adding to a map (a key and its value, PUT) or a list (an index and the value, POST:
+// the items from there on move up; -1 is the end), without writing it whole. `known()`:
+// a map's keys as last read, so an existing one isn't overwritten.
+function adder(kind, path, known) {
+ const line = el("div", { class: "leaf add", title: kind === "map"
+ ? "Add a key" : "Insert an item" }, "+ add");
+ line.addEventListener("click", () => {
+ if (line.querySelector(".editor")) return;
+ const map = kind === "map";
+ line.replaceChildren(editor(path, "", saved => {
+ line.replaceChildren("+ add");
+ edited(path, saved);
+ }, {
+ extra: map
+ ? { label: "key", initial: "" }
+ : { label: "index", initial: "-1", hint: "where: 0 is the first; -1 the end" },
+ check: extra => map
+ ? (extra === "" ? "A key, please."
+ : extra.includes("/") ? "A key with \"/\" can't be named in a path."
+ : known().includes(extra) ? "\"" + extra + "\" exists already: edit it instead."
+ : null)
+ : (/^(-1|\d+)$/.test(extra.trim()) ? null : "The index: 0 or more, or -1 for the end."),
+ send: (body, type, extra) => map
+ ? fetch(storeUrl([...path, extra], false), { method: "PUT", body,
+ headers: { "Content-Type": type } })
+ : fetch(storeUrl(path, false) + "?index=" + encodeURIComponent(extra.trim()),
+ { method: "POST", body, headers: { "Content-Type": type } }),
+ }));
+ });
+ return line;
+}
+
// Store paths name keys below the root; the root itself isn't written whole.
const editable = path => path.length > 0 && addressable(path);
// After an edit: what changed is read again (the node's `document` event follows).
@@ -688,7 +741,12 @@ function editableLeaf(key, shown, value, path) {
function documentEntry(key, summary, path) {
if (summary.size === 0 && summary.type in EMPTY) {
- return editableLeaf(key, EMPTY[summary.type], EMPTY_VALUE[summary.type](), path);
+ const leaf = editableLeaf(key, EMPTY[summary.type], EMPTY_VALUE[summary.type](), path);
+ // An empty map or list takes its first entry here.
+ if ((summary.type === "map" || summary.type === "list") && editable(path)) {
+ return el("div", {}, leaf, el("div", { class: "nested" }, adder(summary.type, path, () => [])));
+ }
+ return leaf;
}
if ("value" in summary) {
return editableLeaf(key, JSON.stringify(summary.value), summary.value, path);
@@ -730,6 +788,7 @@ function documentEntry(key, summary, path) {
}
const id = JSON.stringify(path);
const children = new Map();
+ let adding = null;
let read = false;
const fill = async () => {
try {
@@ -756,11 +815,19 @@ function documentEntry(key, summary, path) {
details.querySelectorAll(":scope > .note").forEach(note => note.remove());
if (!children.size) details.append(el("div", { class: "note" }, "empty"));
for (const k of Object.keys(entries)) details.append(children.get(k).element);
+ // Last: adding a key.
+ adding = adding || adder("map", path, () => [...children.keys()]);
+ details.append(adding);
} else {
const value = await readStore(path, false);
const shown = el("pre", {}, typeof value === "string" ? value : JSON.stringify(value, null, 2));
const before = details.querySelector(":scope > pre");
if (before) before.replaceWith(shown); else details.append(shown);
+ // A list: inserting an item, after it.
+ if (summary.type === "list") {
+ adding = adding || adder("list", path, () => []);
+ details.append(adding);
+ }
}
} catch (error) {
details.querySelectorAll(":scope > .note").forEach(note => note.remove());