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:
@@ -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>
|
||||
|
||||
@@ -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) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user