aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/dashboard
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/dashboard')
-rw-r--r--src/api/dashboard/dashboard.html170
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);