feat(ui): requirements live in the device inspector + admin tab

m wants 'this device connects to ...' declared from the device itself,
not a global sidebar list.

- Device inspector gets a '+ Requirement' button under its Requirements
  section. Click pre-fills the modal with from_device_id = this device,
  so m only picks the other endpoint + cable type + must/nice.
- Existing requirement rows in the device inspector remain clickable —
  they jump to the requirement's own inspector pane.
- New 5th admin tab 'Requirements' carries the all-projects-wide list
  with Edit + Delete actions per row and a single '+ Add requirement'
  entry point (uses the same modal). Edit/Add close the admin modal
  so the requirement modal isn't stacked on top.
- Left sidebar 'Requirements' section + '+ Requirement' button removed.
  The legend + tools sections reclaim the freed real estate.

renderRequirements() and the renderRequirements call site in render()
deleted (no consumer left). #btn-add-requirement boot wiring removed.
This commit is contained in:
mAi
2026-05-16 11:59:08 +02:00
parent f08c48e9b5
commit 9aa395854d
2 changed files with 91 additions and 46 deletions

View File

@@ -34,11 +34,6 @@
<ul id="legend-list" class="legend-list"></ul>
<button type="button" id="btn-add-type" class="btn btn-tiny">+ Type</button>
</section>
<section class="requirements">
<h2 class="sidebar-heading">Requirements</h2>
<ul id="requirement-list" class="requirement-list"></ul>
<button type="button" id="btn-add-requirement" class="btn btn-tiny">+ Requirement</button>
</section>
<section class="tools">
<h2 class="sidebar-heading">Tools</h2>
<ul class="tool-list">
@@ -237,6 +232,7 @@
<button type="button" class="admin-tab" data-admin-tab="cable-types" role="tab">Cable types</button>
<button type="button" class="admin-tab" data-admin-tab="device-types" role="tab">Device types</button>
<button type="button" class="admin-tab" data-admin-tab="setup-templates" role="tab">Setup templates</button>
<button type="button" class="admin-tab" data-admin-tab="requirements" role="tab">Requirements</button>
</nav>
<section class="admin-body" id="admin-body" role="tabpanel"></section>
</div>

View File

@@ -668,7 +668,10 @@ function renderInspectorDevice(body, id) {
</div>
<p class="section-title">Requirements</p>
<div id="dev-reqs">${reqsHtml}</div>
<div class="inspector-actions">
<div class="inspector-actions" style="margin-top: 4px;">
<button type="button" class="btn btn-tiny" id="dev-add-req">+ Requirement</button>
</div>
<div class="inspector-actions" style="margin-top: 12px;">
<button type="button" class="btn btn-danger btn-tiny" id="dev-delete">Delete device</button>
</div>
`;
@@ -729,6 +732,18 @@ function renderInspectorDevice(body, id) {
});
});
// + Requirement — open the modal pre-filled with this device as the
// "from" endpoint. Refuses if the project has fewer than 2 devices
// (a requirement needs two distinct endpoints).
body.querySelector("#dev-add-req").addEventListener("click", () => {
if (!state.active) return;
if (state.devices.length < 2) {
alert("Add a second device before declaring a requirement.");
return;
}
openRequirementModal(null, { from: d.id });
});
// +Port — switch the inspector to the new-port form. m fills in
// type + edge + label and clicks Create; no canvas click required.
body.querySelector("#dev-add-port").addEventListener("click", () => {
@@ -1344,46 +1359,11 @@ function bindDebouncedRename(input, persist) {
function render() {
renderProjectPicker();
renderLegend();
renderRequirements();
renderCanvas();
renderEmptyHint();
renderInspector();
}
// ---------- requirements sidebar ---------- //
function renderRequirements() {
const ul = $("#requirement-list");
ul.innerHTML = "";
const deviceById = new Map(state.devices.map((d) => [d.id, d]));
const cableTypeById = new Map(state.cableTypes.map((t) => [t.id, t]));
for (const r of state.requirements) {
const a = deviceById.get(r.from_device_id);
const b = deviceById.get(r.to_device_id);
if (!a || !b) continue; // a device delete cascade — UI will rerender soon
const ct = r.preferred_cable_type_id != null ? cableTypeById.get(r.preferred_cable_type_id) : null;
const li = document.createElement("li");
li.className = "requirement-row";
li.dataset.id = String(r.id);
if (state.selection?.kind === "requirement" && state.selection.id === r.id) {
li.setAttribute("aria-current", "true");
}
const cableLabel = ct ? `${ct.name}` : "solver picks";
li.innerHTML = `
<span class="pair">
${escapeHtml(a.name)}${escapeHtml(b.name)}
<span class="type"> · ${escapeHtml(cableLabel)}</span>
</span>
<span class="badge ${r.must_connect ? "must" : "nice"}">${r.must_connect ? "must" : "nice"}</span>
`;
li.addEventListener("click", () => {
state.selection = { kind: "requirement", id: r.id };
render();
});
ul.append(li);
}
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
@@ -2505,6 +2485,7 @@ function switchAdminTab(name) {
case "cable-types": return renderAdminCableTypes(body);
case "device-types": return renderAdminDeviceTypes(body);
case "setup-templates": return renderAdminSetupTemplates(body);
case "requirements": return renderAdminRequirements(body);
}
}
@@ -2793,6 +2774,79 @@ function renderAdminSetupTemplates(body) {
`;
}
// ---------- admin: requirements (all) ---------- //
function renderAdminRequirements(body) {
if (!state.active) {
body.innerHTML = `<p class="admin-empty">Pick a project to see its requirements.</p>`;
return;
}
const deviceById = new Map(state.devices.map((d) => [d.id, d]));
const cableTypeBy = new Map(state.cableTypes.map((t) => [t.id, t]));
const rows = state.requirements.map((r) => {
const a = deviceById.get(r.from_device_id);
const b = deviceById.get(r.to_device_id);
const ct = r.preferred_cable_type_id != null ? cableTypeBy.get(r.preferred_cable_type_id) : null;
return `
<div class="admin-row" data-req-id="${r.id}">
<div class="admin-row-title">
<span>
${escapeHtml(a?.name ?? "?")}${escapeHtml(b?.name ?? "?")}
<span class="muted" style="font-weight:normal;font-size:11px;"> · ${escapeHtml(ct?.name ?? "solver picks")}</span>
<span class="locked-badge" style="margin-left:6px;">${r.must_connect ? "must" : "nice"}</span>
</span>
<span style="color: var(--text-muted); font-size: 11px;">#${r.id}</span>
</div>
${r.notes ? `<p class="muted" style="font-size:12px;margin:0;">${escapeHtml(r.notes)}</p>` : ""}
<div class="actions">
<button type="button" class="btn btn-tiny adm-edit">Edit</button>
<button type="button" class="btn btn-danger btn-tiny adm-delete">Delete</button>
</div>
</div>
`;
}).join("") || `<p class="admin-empty">No requirements yet.</p>`;
body.innerHTML = `
<p class="muted" style="font-size:12px;margin:0 0 12px 0;">
Requirements are the solver's input — "device A must connect to device B".
Add new ones from the per-device inspector (more contextual); manage them here.
</p>
${rows}
<div class="admin-add-row">
<button type="button" class="btn btn-tiny" id="adm-req-new"
${state.devices.length < 2 ? "disabled" : ""}>+ Add requirement</button>
${state.devices.length < 2
? '<span class="muted" style="margin-left:8px;">(needs ≥ 2 devices)</span>'
: ""}
</div>
`;
for (const row of body.querySelectorAll(".admin-row[data-req-id]")) {
const rid = Number(row.getAttribute("data-req-id"));
row.querySelector(".adm-edit").addEventListener("click", () => {
const r = state.requirements.find((x) => x.id === rid);
if (!r) return;
const dlg = $("#modal-admin");
dlg.close();
openRequirementModal(r);
});
row.querySelector(".adm-delete").addEventListener("click", async () => {
if (!confirm("Delete this requirement?")) return;
try {
await deleteRequirement(state.active.id, rid);
state.requirements = state.requirements.filter((r) => r.id !== rid);
switchAdminTab("requirements");
render();
} catch (e) { alert(`Delete failed: ${e.message}`); }
});
}
const newBtn = body.querySelector("#adm-req-new");
if (newBtn) {
newBtn.addEventListener("click", () => {
$("#modal-admin").close();
openRequirementModal(null);
});
}
}
// ---------- boot ---------- //
async function boot() {
@@ -2809,11 +2863,6 @@ async function boot() {
$("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));
$("#btn-delete-project").addEventListener("click", openDeleteProjectModal);
$("#btn-admin").addEventListener("click", openAdminModal);
$("#btn-add-requirement").addEventListener("click", () => {
if (!state.active) { alert("Pick a project first"); return; }
if (state.devices.length < 2) { alert("Need at least two devices to add a requirement."); return; }
openRequirementModal(null);
});
$("#btn-solve").addEventListener("click", openSolveModal);
$("#btn-apply-template").addEventListener("click", openApplyTemplateModal);
$("#btn-export").addEventListener("click", exportCurrentProject);