diff options
Diffstat (limited to 'src/api/dashboard')
| -rw-r--r-- | src/api/dashboard/dashboard.html | 170 |
1 files changed, 102 insertions, 68 deletions
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index e103797..9833426 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -86,8 +86,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 button.add { font: inherit; line-height: 1; padding: 0 4px; margin-left: 4px; + color: var(--muted); background: none; border: 1px solid var(--line); border-radius: 3px; + cursor: pointer; } + .tree button.add:hover { color: var(--fg); border-color: var(--muted); } .tree .nested { padding-left: 1.1em; } .editor input.extra[type=text] { width: 12em; margin-bottom: 2px; display: block; } .editor { margin: 2px 0 4px; } @@ -604,8 +606,6 @@ async function readStore(path, shallow) { if (!response.ok) throw new Error("HTTP " + response.status); return response.json(); } -// One entry: a short scalar as it is; anything else a branch read when opened. A -// branch's entries are kept across reads, so what's open inside stays open. // An empty map, list or text: shown as it is, nothing to open. const EMPTY = { map: "{}", list: "[]", text: "\"\"" }; const EMPTY_VALUE = { map: () => ({}), list: () => [], text: () => "" }; @@ -613,7 +613,7 @@ const EMPTY_VALUE = { map: () => ({}), list: () => [], text: () => "" }; // --- Editing: a double click on an entry turns it into a field holding its contents; // 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. +// switches. `done(saved, response)` when saved or cancelled. // `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). @@ -622,10 +622,8 @@ function editor(path, value, done, options = {}) { const fail = text => { message.className = "error"; message.textContent = text; }; const asText = typeof value === "string"; const text = asText ? value : JSON.stringify(value, null, 2); - const long = text.includes("\n") || text.length > 60 || (value !== null && typeof value === "object"); - const field = long - ? el("textarea", { rows: String(Math.min(20, text.split("\n").length + 1)), spellcheck: "false" }) - : el("input", { type: "text", spellcheck: "false" }); + const field = el("textarea", { spellcheck: "false", + rows: String(Math.max(2, Math.min(20, text.split("\n").length + 1))) }); field.value = text; const json = el("input", { type: "checkbox" }); json.checked = !asText; @@ -670,7 +668,7 @@ function editor(path, value, done, options = {}) { try { why = (await response.json()).error || why; } catch { /* no body */ } throw new Error("HTTP " + response.status + (why ? ": " + why : "")); } - done(true); + done(true, response); } catch (error) { save.disabled = false; fail("Not saved: " + error.message); @@ -678,66 +676,88 @@ function editor(path, value, done, options = {}) { }; save.addEventListener("click", submit); cancel.addEventListener("click", () => done(false)); + // Enter in the short field saves; in the area, Ctrl or ⌘ and Enter does. 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" && (line || event.ctrlKey || event.metaKey)) { event.preventDefault(); submit(); } }); - keys(field, !long); + keys(field, false); 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)); + el("span", { class: "note" }, "Ctrl+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); + +// Store paths name keys below the root; the root itself isn't written whole. +const editable = path => path.length > 0 && addressable(path); +// Branches whose reading again waited for an edit in them to end, by path (as JSON). +const documentDeferred = new Set(); +// After an edit: what changed is read again (the node's `document` event follows), +// and what was waiting for the edit; `opened`, what it made, shown open. +function edited(path, saved, opened = path) { + if (saved) documentOpen.add(JSON.stringify(opened)); + release(); + const waiting = [...documentDeferred].map(id => JSON.parse(id)); + documentDeferred.clear(); + if (saved || waiting.length) documentChanged({ paths: saved ? [path, ...waiting] : waiting }); +} +// Whether `element` holds a field being edited: left as it is until it's done. +const editing = element => element.querySelector(".editor") !== null; + +// Adding to a map (a key and its value, PUT /store/<path>/<key>) or a list (an index +// and the value, POST /store/<path>/<index>: the items from there on move up; -1 is the +// end), without writing it whole: a "+" on the row, the field below it. `known()`: a +// map's keys as last read, so an existing one isn't overwritten. +function addButton(kind, path, known, place) { + const map = kind === "map"; + const button = el("button", { type: "button", class: "add", + title: map ? "Add a key" : "Insert an item" }, "+"); + button.addEventListener("click", event => { + // Not a click on the row: it would open or close it. + event.preventDefault(); + event.stopPropagation(); + const area = place(); + if (editing(area)) return; + const form = editor(path, "", async (saved, response) => { + form.remove(); + let added = path; + if (saved) { + if (map) added = [...path, form.dataset.extra]; + else { + try { added = [...path, String((await response.json()).index)]; } catch { /* path */ } + } + } + edited(path, saved, added); }, { 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 } }), - })); + check: extra => { + form.dataset.extra = extra; + return 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) => fetch(storeUrl([...path, map ? extra : extra.trim()], false), + { method: map ? "PUT" : "POST", body, headers: { "Content-Type": type } }), + }); + area.prepend(form); }); - return line; + return button; } -// 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). -function edited(path, saved) { - release(); - if (saved) documentChanged({ paths: [path] }); -} function editableLeaf(key, shown, value, path) { const leaf = el("div", { class: "leaf" }, el("span", { class: "key" }, key + ": "), shown); if (!editable(path)) return leaf; leaf.title = "Double-click to edit"; leaf.addEventListener("dblclick", event => { - if (leaf.querySelector(".editor")) return; + if (editing(leaf)) return; event.preventDefault(); window.getSelection().removeAllRanges(); const before = [...leaf.childNodes]; @@ -749,12 +769,16 @@ function editableLeaf(key, shown, value, path) { return leaf; } +// One entry: a short scalar as it is; anything else a branch read when opened. A +// branch's entries are kept across reads, so what's open inside stays open. function documentEntry(key, summary, path) { if (summary.size === 0 && summary.type in EMPTY) { const leaf = editableLeaf(key, EMPTY[summary.type], EMPTY_VALUE[summary.type](), path); - // An empty map or list takes its first entry here. + // An empty map or list takes its first entry here: "+" on its row, the field below. if ((summary.type === "map" || summary.type === "list") && editable(path)) { - return el("div", {}, leaf, el("div", { class: "nested" }, adder(summary.type, path, () => []))); + const below = el("div", { class: "nested" }); + leaf.append(" ", addButton(summary.type, path, () => [], () => below)); + return el("div", {}, leaf, below); } return leaf; } @@ -798,20 +822,38 @@ function documentEntry(key, summary, path) { } const id = JSON.stringify(path); const children = new Map(); - let adding = null; + const browsed = summary.type === "map" || summary.type === "list"; + // Adding: on the row, the field just below it (the branch opened for it). + if (browsed && (editable(path) || path.length === 0)) { + head.append(" ", addButton(summary.type, path, () => [...children.keys()], () => { + details.open = true; + return { prepend: form => head.after(form), querySelector: q => details.querySelector(":scope > " + q) }; + })); + } let read = false; const fill = async () => { + // Being edited as a whole: read again once that's done. + if (details.classList.contains("editing")) { documentDeferred.add(id); return; } try { - if (summary.type === "map") { + if (browsed) { const shallow = await readStore(path, true); details.updated(shallow); const entries = shallow.entries || {}; + // A list's items by index (the node sends them sorted as text). + const keys = Object.keys(entries); + if (summary.type === "list") keys.sort((a, b) => Number(a) - Number(b)); for (const [k, child] of children) { - if (!(k in entries)) { child.element.remove(); children.delete(k); } + if (k in entries) continue; + // Being edited: kept until that's done. + if (editing(child.element)) { documentDeferred.add(id); continue; } + child.element.remove(); + children.delete(k); } - for (const [k, newer] of Object.entries(entries)) { + for (const k of keys) { + const newer = entries[k]; const known = children.get(k); if (known && JSON.stringify(known.summary) === JSON.stringify(newer)) continue; + if (known && editing(known.element)) { documentDeferred.add(id); continue; } if (known && known.element.sameKind && known.element.sameKind(newer)) { known.element.updated(newer); known.summary = newer; @@ -821,27 +863,19 @@ function documentEntry(key, summary, path) { if (known) known.element.replaceWith(element); children.set(k, { element, summary: newer }); } - // In the node's order (sorted); moving keeps their state. + // In order; moving keeps their state. 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); + for (const k of keys) if (children.has(k)) details.append(children.get(k).element); } 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()); - details.append(el("div", { class: "note" }, "Not read: " + error.message)); + details.append(el("div", { class: "note error" }, "Not read: " + error.message)); } }; const open = () => { @@ -857,7 +891,8 @@ function documentEntry(key, summary, path) { return details; } // The node says what changed (paths), or that anything may have (`all`). -// Changes waiting while the document box is busy (`busy`): merged, applied after. +// Changes waiting while text in the document box is selected (`busy`): merged, applied +// after. A field being edited holds only what it is in (`editing`, `documentDeferred`). let documentPending = null; function documentChanged(change) { if (busy(document.getElementById("document-box"))) { @@ -874,10 +909,9 @@ function documentChanged(change) { document.getElementById("document").replaceChildren( documentEntry("store", { type: "map", size: "…" }, [])); -// A box is busy while text in it is selected, or something in it is being edited: -// drawing it anew would lose that, so it waits (`held`) until it's done. +// A box is busy while text in it is selected: drawing it anew would lose the selection, +// so it waits (`held`) until it's gone. function busy(box) { - if (box.querySelector(".editor")) return true; const selection = window.getSelection(); if (!selection || selection.isCollapsed || !selection.rangeCount) return false; return box.contains(selection.getRangeAt(0).commonAncestorContainer); |
