From 5ea3983bbaa00c7f9c881b404a9dd61921f934bd Mon Sep 17 00:00:00 2001 From: tv Date: Fri, 30 Oct 2026 20:35:27 +0100 Subject: dashboard: allow adding new items --- src/api/dashboard/dashboard.html | 87 +++++++++++++++++++++++++++++++++++----- 1 file changed, 77 insertions(+), 10 deletions(-) (limited to 'src/api/dashboard/dashboard.html') 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/ (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()); -- cgit v1.3.1