339 lines
9.6 KiB
JavaScript
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();
|