Files
box-manifest-android/web/app.js
T
2026-08-26 18:02:37 -05:00

339 lines
9.6 KiB
JavaScript

const state = {
root: null,
nodes: new Map(),
selectedId: null,
importPreview: null,
};
const elements = {
status: document.querySelector("#status"),
tree: document.querySelector("#tree"),
selected: document.querySelector("#selected"),
refresh: document.querySelector("#refresh"),
createForm: document.querySelector("#create-form"),
createParent: document.querySelector("#create-parent"),
createName: document.querySelector("#create-name"),
createQuantity: document.querySelector("#create-quantity"),
updateForm: document.querySelector("#update-form"),
updateName: document.querySelector("#update-name"),
updateQuantity: document.querySelector("#update-quantity"),
moveForm: document.querySelector("#move-form"),
moveParent: document.querySelector("#move-parent"),
moveChildren: document.querySelector("#move-children"),
deleteButton: document.querySelector("#delete"),
importForm: document.querySelector("#import-form"),
importParent: document.querySelector("#import-parent"),
importManifest: document.querySelector("#import-manifest"),
importPreview: document.querySelector("#import-preview"),
importSummary: document.querySelector("#import-summary"),
importWarnings: document.querySelector("#import-warnings"),
importNormalized: document.querySelector("#import-normalized"),
importCommit: document.querySelector("#import-commit"),
importCancel: document.querySelector("#import-cancel"),
};
async function api(path, options = {}) {
const response = await fetch(path, {
...options,
headers: options.body ? { "Content-Type": "application/json" } : undefined,
});
if (response.status === 204) {
return null;
}
const body = await response.json();
if (!response.ok) {
throw new Error(
body.message || `Request failed with status ${response.status}`,
);
}
return body;
}
async function loadTree() {
try {
state.root = await api("/api/tree");
state.nodes.clear();
indexNode(state.root);
const requestedCode = new URLSearchParams(window.location.search)
.get("code")
?.toUpperCase();
if (requestedCode) {
state.selectedId =
[...state.nodes.values()].find(
(node) => node.lookupCode === requestedCode,
)?.id || null;
}
if (state.selectedId && !state.nodes.has(state.selectedId)) {
state.selectedId = null;
}
render();
setStatus("");
} catch (error) {
setStatus(error.message);
}
}
function indexNode(node) {
state.nodes.set(node.id, node);
for (const child of node.children) {
indexNode(child);
}
}
function render() {
elements.tree.replaceChildren(renderNode(state.root));
renderSelection();
renderImportParents();
renderImportPreview();
}
function renderImportParents() {
const previous =
elements.importParent.value || state.selectedId || "unsorted";
elements.importParent.replaceChildren();
for (const node of state.nodes.values()) {
const option = document.createElement("option");
option.value = node.id;
option.textContent = nodePath(node)
.map((part) => part.name)
.join(" / ");
elements.importParent.append(option);
}
elements.importParent.value = state.nodes.has(previous)
? previous
: "unsorted";
}
function nodePath(node) {
const path = [node];
let current = node;
while (current.parentId) {
current = state.nodes.get(current.parentId);
if (!current) break;
path.unshift(current);
}
return path;
}
function renderImportPreview() {
const preview = state.importPreview;
elements.importPreview.hidden = !preview;
elements.importCommit.disabled = !preview;
if (!preview) {
elements.importSummary.textContent = "";
elements.importWarnings.replaceChildren();
elements.importNormalized.textContent = "";
return;
}
elements.importSummary.textContent = `${preview.summary.nodes} nodes, maximum depth ${preview.summary.maximumDepth}`;
elements.importWarnings.replaceChildren();
for (const warning of preview.warnings) {
const item = document.createElement("li");
item.textContent = warning;
elements.importWarnings.append(item);
}
elements.importNormalized.replaceChildren(
renderImportNodes(preview.manifest.nodes),
);
}
function renderImportNodes(nodes) {
const list = document.createElement("ul");
for (const node of nodes) {
const item = document.createElement("li");
const label = document.createElement("strong");
label.textContent = node.name;
item.append(label);
if (node.quantity !== undefined && node.quantity !== null) {
item.append(` — quantity ${node.quantity}`);
}
if (node.children?.length) {
item.append(renderImportNodes(node.children));
}
list.append(item);
}
return list;
}
function renderNode(node) {
const list = document.createElement("ul");
const item = document.createElement("li");
const button = document.createElement("button");
button.type = "button";
button.textContent =
node.quantity === null ? node.name : `${node.name} (${node.quantity})`;
button.addEventListener("click", () => {
state.selectedId = node.id;
renderSelection();
});
item.append(button);
if (node.children.length > 0) {
for (const child of node.children) {
item.append(renderNode(child));
}
}
list.append(item);
return list;
}
function renderSelection() {
const node = state.nodes.get(state.selectedId);
const hasSelection = Boolean(node);
const protectedNode = node?.id === "root" || node?.id === "unsorted";
elements.selected.textContent = node
? `${node.name} [${node.lookupCode}]`
: "None";
elements.createParent.textContent = `Parent: ${node?.name || "Unsorted"}`;
elements.updateName.value = node?.name || "";
elements.updateQuantity.value = node?.quantity ?? "";
for (const control of elements.updateForm.elements) {
control.disabled = !hasSelection || protectedNode;
}
for (const control of elements.moveForm.elements) {
control.disabled = !hasSelection || protectedNode;
}
elements.deleteButton.disabled = !hasSelection || protectedNode;
elements.moveParent.replaceChildren();
for (const candidate of state.nodes.values()) {
if (candidate.id === state.selectedId) {
continue;
}
const option = document.createElement("option");
option.value = candidate.id;
option.textContent = candidate.name;
elements.moveParent.append(option);
}
}
function quantityFrom(input) {
return input.value === "" ? null : Number(input.value);
}
elements.refresh.addEventListener("click", loadTree);
elements.createForm.addEventListener("submit", async (event) => {
event.preventDefault();
const request = {
name: elements.createName.value,
quantity: quantityFrom(elements.createQuantity),
};
if (state.selectedId) {
request.parentId = state.selectedId;
}
await perform(async () => {
const node = await api("/api/nodes", {
method: "POST",
body: JSON.stringify(request),
});
state.selectedId = node.id;
elements.createForm.reset();
});
});
elements.updateForm.addEventListener("submit", async (event) => {
event.preventDefault();
await perform(() =>
api(`/api/nodes/${state.selectedId}`, {
method: "PATCH",
body: JSON.stringify({
name: elements.updateName.value,
quantity: quantityFrom(elements.updateQuantity),
}),
}),
);
});
elements.moveForm.addEventListener("submit", async (event) => {
event.preventDefault();
await perform(() =>
api(`/api/nodes/${state.selectedId}/move`, {
method: "POST",
body: JSON.stringify({
targetParentId: elements.moveParent.value,
childHandling: elements.moveChildren.value,
}),
}),
);
});
elements.deleteButton.addEventListener("click", async () => {
const node = state.nodes.get(state.selectedId);
if (!node || !window.confirm(`Delete ${node.name}?`)) {
return;
}
await perform(async () => {
await api(`/api/nodes/${node.id}`, { method: "DELETE" });
state.selectedId = null;
});
});
elements.importForm.addEventListener("submit", async (event) => {
event.preventDefault();
try {
setStatus("");
const manifest = JSON.parse(elements.importManifest.value);
state.importPreview = await api("/api/imports/tree/preview", {
method: "POST",
body: JSON.stringify({
parentId: elements.importParent.value,
manifest,
}),
});
renderImportPreview();
} catch (error) {
state.importPreview = null;
renderImportPreview();
setStatus(
error instanceof SyntaxError
? `Invalid JSON: ${error.message}`
: error.message,
);
}
});
elements.importManifest.addEventListener("input", clearImportPreview);
elements.importParent.addEventListener("change", clearImportPreview);
elements.importCancel.addEventListener("click", clearImportPreview);
elements.importCommit.addEventListener("click", async () => {
const preview = state.importPreview;
if (!preview) return;
try {
setStatus("");
const result = await api("/api/imports/tree/commit", {
method: "POST",
body: JSON.stringify({ planId: preview.planId }),
});
state.importPreview = null;
elements.importManifest.value = "";
await loadTree();
setStatus(`Imported ${result.created.nodes} nodes`);
} catch (error) {
setStatus(error.message);
}
});
function clearImportPreview() {
state.importPreview = null;
renderImportPreview();
}
async function perform(action) {
try {
setStatus("");
await action();
await loadTree();
} catch (error) {
setStatus(error.message);
}
}
function setStatus(message) {
elements.status.textContent = message;
}
loadTree();