feat(ui): type-aware device creation + port rendering
Modal-driven +Dev (replaces the v3 inline namer): - Tool armed → click on canvas captures the click position + frame_id from frameAt(p), then opens a #modal-new-device dialog. - Dialog has a <select> grouped by `kind` for built-ins, then project-custom rows, then "Custom (no type)" at the bottom. - Default selection is the first built-in (NAS). Name input is auto-pre-filled to <type-name>, bumping to <type-name>-N if a name collision is detected in the current device list. - Submit POSTs name + type_id + x/y/w/h + frame_id. Server seeds the ports in the same transaction; we re-snapshot to pick them up. Canvas: - After each device's <rect> + label, render the device's ports as white-filled <circle>s with stroke = the port's cable_type colour. - Position: (device.x + port.x_offset, device.y + port.y_offset). The seeder's "evenly along the edge" layout means ports already sit on the device's bottom edge by default and follow the device on drag (because they re-render from the same x/y on every renderCanvas). - Ports themselves are `pointer-events: none` for slice 4 — selection remains device-level. Per-port click semantics ship in slice 7 (manual cable draw). Inspector device pane: - New "type" row showing the type name + a "(custom)" badge for project-custom types, or "Custom (no type)" for freeform. - New "Ports" section with one row per seeded port: cable-type-colour swatch, label, "unconnected" placeholder. Label falls back to the cable type's name when the seeded label_prefix was blank. State + snapshot: - state.ports populated from snap.ports; cleared on project switch / 404. - state.deviceTypes hydrated from GET /api/projects/:pid/device-types after the snapshot loads. Failure of that fetch is non-fatal — the +Dev modal just shows "Custom (no type)" only. - Delete-device cleans up its ports from state.ports too (server-side CASCADE already handles persistence).
This commit is contained in:
@@ -113,6 +113,28 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- New device (slice 4: type-aware) -->
|
||||
<dialog id="modal-new-device" class="modal" aria-labelledby="nd-title">
|
||||
<form method="dialog" id="form-new-device">
|
||||
<h2 id="nd-title">New device</h2>
|
||||
<label class="field">
|
||||
<span>Type</span>
|
||||
<select id="nd-type" name="type_id" required>
|
||||
<option value="">Loading…</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Name</span>
|
||||
<input type="text" name="name" id="nd-name" required autocomplete="off" />
|
||||
</label>
|
||||
<p class="form-error" id="nd-error" hidden></p>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary">Create</button>
|
||||
<button type="button" class="btn" data-close>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Delete Project confirm -->
|
||||
<dialog id="modal-delete-project" class="modal" aria-labelledby="dp-title">
|
||||
<form method="dialog" id="form-delete-project">
|
||||
|
||||
@@ -12,10 +12,19 @@
|
||||
* @typedef {{ id: number, project_id: number, name: string,
|
||||
* x: number, y: number, width: number, height: number }} Frame
|
||||
* @typedef {{ id: number, project_id: number, frame_id: number|null,
|
||||
* name: string, color: string,
|
||||
* type_id: number|null, name: string, color: string,
|
||||
* x: number, y: number, width: number, height: number }} Device
|
||||
* @typedef {{ id: number, project_id: number, frame_id: number|null,
|
||||
* label: string, x: number, y: number }} IOMarker
|
||||
* @typedef {{ id: number, project_id: number, device_id: number,
|
||||
* type_id: number, label: string|null,
|
||||
* x_offset: number, y_offset: number }} Port
|
||||
* @typedef {{ id: number, device_type_id: number, cable_type_id: number,
|
||||
* label_prefix: string, count: number, edge: string,
|
||||
* sort_order: number }} DeviceTypePort
|
||||
* @typedef {{ id: number, project_id: number|null, name: string,
|
||||
* kind: string, icon: string|null, description: string,
|
||||
* built_in: boolean, ports: DeviceTypePort[] }} DeviceType
|
||||
*/
|
||||
|
||||
const API = "/api";
|
||||
@@ -25,9 +34,11 @@ const IO_SIZE = 30; // diamond bounding-box side (the rotated rect's width/heigh
|
||||
const state = {
|
||||
/** @type {Project[]} */ projects: [],
|
||||
/** @type {CableType[]} */ cableTypes: [],
|
||||
/** @type {DeviceType[]} */ deviceTypes: [],
|
||||
/** @type {Project | null} */ active: null,
|
||||
/** @type {Frame[]} */ frames: [],
|
||||
/** @type {Device[]} */ devices: [],
|
||||
/** @type {Port[]} */ ports: [],
|
||||
/** @type {IOMarker[]} */ ioMarkers: [],
|
||||
activeTypeId: /** @type {number|null} */ (null),
|
||||
/** "frame" | "device" | "io" | null */
|
||||
@@ -78,6 +89,8 @@ const createIOMarker = (pid, body) => api("POST", `/projects/${pid}/io-markers
|
||||
const patchIOMarker = (pid, id, body) => api("PATCH", `/projects/${pid}/io-markers/${id}`, body);
|
||||
const deleteIOMarker = (pid, id) => api("DELETE", `/projects/${pid}/io-markers/${id}`);
|
||||
|
||||
const listDeviceTypesForProject = (pid) => api("GET", `/projects/${pid}/device-types`);
|
||||
|
||||
// ---------- DOM helpers ---------- //
|
||||
|
||||
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
|
||||
@@ -229,6 +242,14 @@ function renderCanvas() {
|
||||
rect.addEventListener("pointerdown", (e) => startDrag(e, "frame", f.id));
|
||||
}
|
||||
|
||||
const portsByDevice = new Map();
|
||||
for (const prt of state.ports) {
|
||||
const arr = portsByDevice.get(prt.device_id) || [];
|
||||
arr.push(prt);
|
||||
portsByDevice.set(prt.device_id, arr);
|
||||
}
|
||||
const cableTypeColor = new Map(state.cableTypes.map((t) => [t.id, t.color]));
|
||||
|
||||
for (const d of state.devices) {
|
||||
const g = svgEl("g", { "data-device-id": d.id });
|
||||
const rect = svgEl("rect", {
|
||||
@@ -246,6 +267,23 @@ function renderCanvas() {
|
||||
});
|
||||
label.textContent = d.name;
|
||||
g.append(rect, label);
|
||||
|
||||
// Render ports as small circles at (device.x + x_offset, device.y + y_offset).
|
||||
// Stroke colour = the cable_type colour the port carries; fill stays white
|
||||
// so the port reads against any device colour.
|
||||
const ports = portsByDevice.get(d.id) || [];
|
||||
for (const prt of ports) {
|
||||
const cx = d.x + prt.x_offset;
|
||||
const cy = d.y + prt.y_offset;
|
||||
const color = cableTypeColor.get(prt.type_id) || "#888";
|
||||
const c = svgEl("circle", {
|
||||
cx, cy, r: 5,
|
||||
class: "port-circle",
|
||||
stroke: color,
|
||||
});
|
||||
g.append(c);
|
||||
}
|
||||
|
||||
gDevices.append(g);
|
||||
rect.addEventListener("pointerdown", (e) => startDrag(e, "device", d.id));
|
||||
}
|
||||
@@ -346,6 +384,20 @@ function renderInspectorDevice(body, id) {
|
||||
const d = state.devices.find((x) => x.id === id);
|
||||
if (!d) { body.innerHTML = ""; return; }
|
||||
const frame = d.frame_id ? state.frames.find((f) => f.id === d.frame_id) : null;
|
||||
const type = d.type_id ? state.deviceTypes.find((t) => t.id === d.type_id) : null;
|
||||
const ports = state.ports.filter((p) => p.device_id === d.id);
|
||||
const cableTypeName = new Map(state.cableTypes.map((t) => [t.id, t.name]));
|
||||
const cableTypeColor = new Map(state.cableTypes.map((t) => [t.id, t.color]));
|
||||
|
||||
const portsHtml = ports.length
|
||||
? ports.map((p) => `
|
||||
<div class="port-row">
|
||||
<span class="swatch" style="background:${cableTypeColor.get(p.type_id) || "#888"}"></span>
|
||||
<span class="label">${(p.label ?? cableTypeName.get(p.type_id) ?? "Port")}</span>
|
||||
<span class="conn">unconnected</span>
|
||||
</div>`).join("")
|
||||
: `<p class="muted" style="font-size:12px">No ports yet.</p>`;
|
||||
|
||||
body.innerHTML = `
|
||||
<p class="section-title">Device</p>
|
||||
<label class="field">
|
||||
@@ -357,18 +409,24 @@ function renderInspectorDevice(body, id) {
|
||||
<input type="color" class="inline-input" id="dev-color" />
|
||||
</label>
|
||||
<dl>
|
||||
<dt>type</dt><dd id="dev-type"></dd>
|
||||
<dt>x</dt><dd id="dev-x"></dd>
|
||||
<dt>y</dt><dd id="dev-y"></dd>
|
||||
<dt>w</dt><dd id="dev-w"></dd>
|
||||
<dt>h</dt><dd id="dev-h"></dd>
|
||||
<dt>frame</dt><dd id="dev-frame"></dd>
|
||||
</dl>
|
||||
<p class="section-title">Ports</p>
|
||||
<div id="dev-ports">${portsHtml}</div>
|
||||
<div class="inspector-actions">
|
||||
<button type="button" class="btn btn-danger btn-tiny" id="dev-delete">Delete device</button>
|
||||
</div>
|
||||
`;
|
||||
body.querySelector("#dev-name").value = d.name;
|
||||
body.querySelector("#dev-color").value = d.color;
|
||||
body.querySelector("#dev-type").textContent = type
|
||||
? `${type.name}${type.built_in ? "" : " (custom)"}`
|
||||
: "Custom (no type)";
|
||||
body.querySelector("#dev-x").textContent = d.x.toFixed(0);
|
||||
body.querySelector("#dev-y").textContent = d.y.toFixed(0);
|
||||
body.querySelector("#dev-w").textContent = d.width.toFixed(0);
|
||||
@@ -401,6 +459,7 @@ function renderInspectorDevice(body, id) {
|
||||
if (!confirm(`Delete device "${d.name}"?`)) return;
|
||||
deleteDevice(state.active.id, d.id).then(() => {
|
||||
state.devices = state.devices.filter((x) => x.id !== d.id);
|
||||
state.ports = state.ports.filter((p) => p.device_id !== d.id);
|
||||
state.selection = null;
|
||||
render();
|
||||
}).catch((e) => alert(`Delete failed: ${e.message}`));
|
||||
@@ -555,6 +614,7 @@ async function activateProject(id) {
|
||||
state.active = null;
|
||||
state.frames = [];
|
||||
state.devices = [];
|
||||
state.ports = [];
|
||||
state.ioMarkers = [];
|
||||
state.selection = null;
|
||||
setActiveInURL(null);
|
||||
@@ -567,15 +627,27 @@ async function activateProject(id) {
|
||||
state.frames = snap.frames || [];
|
||||
state.devices = snap.devices || [];
|
||||
state.ioMarkers = snap.io_markers || [];
|
||||
state.ports = snap.ports || [];
|
||||
state.cableTypes = snap.cable_types || [];
|
||||
state.selection = null;
|
||||
setActiveInURL(id);
|
||||
// Hydrate the device-type catalog for this project — used by the
|
||||
// +Dev modal's dropdown + the device inspector's "Type" row. Done in
|
||||
// parallel after snapshot loads (small response, doesn't gate render).
|
||||
try {
|
||||
state.deviceTypes = await listDeviceTypesForProject(id) || [];
|
||||
} catch (_) {
|
||||
// Don't fail the whole load if catalog fetch fails — the +Dev
|
||||
// modal can show a degraded "Custom only" mode.
|
||||
state.deviceTypes = [];
|
||||
}
|
||||
render();
|
||||
} catch (err) {
|
||||
if (err.status === 404) {
|
||||
state.active = null;
|
||||
state.frames = [];
|
||||
state.devices = [];
|
||||
state.ports = [];
|
||||
state.ioMarkers = [];
|
||||
setActiveInURL(null);
|
||||
render();
|
||||
@@ -719,20 +791,110 @@ async function placeDeviceAt(p) {
|
||||
const W = 100, H = 35;
|
||||
const x = p.x - W / 2;
|
||||
const y = p.y - H / 2;
|
||||
const name = await promptInline("Device name", p.x, p.y);
|
||||
if (!name || !state.active) return;
|
||||
const frame = frameAt(p.x, p.y);
|
||||
try {
|
||||
const d = await createDevice(state.active.id, {
|
||||
name, x, y, width: W, height: H,
|
||||
frame_id: frame ? frame.id : undefined,
|
||||
});
|
||||
state.devices.push(d);
|
||||
state.selection = { kind: "device", id: d.id };
|
||||
render();
|
||||
} catch (err) {
|
||||
alert(`Create device failed: ${err.message}`);
|
||||
// Modal-driven flow (v4 slice 4): pick type + name in one form. Click
|
||||
// position is captured here and POSTed on submit.
|
||||
openNewDeviceModal({ x, y, width: W, height: H, frame_id: frame?.id ?? null });
|
||||
}
|
||||
|
||||
function nextNameFor(typeName) {
|
||||
// Auto-pick a name like "PC" / "PC-2" / "PC-3" against current devices.
|
||||
const taken = new Set(state.devices.map((d) => d.name));
|
||||
if (!taken.has(typeName)) return typeName;
|
||||
for (let i = 2; i < 1000; i++) {
|
||||
const candidate = `${typeName}-${i}`;
|
||||
if (!taken.has(candidate)) return candidate;
|
||||
}
|
||||
return typeName;
|
||||
}
|
||||
|
||||
function openNewDeviceModal(geom) {
|
||||
if (!state.active) return;
|
||||
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-new-device"));
|
||||
const form = /** @type {HTMLFormElement} */ ($("#form-new-device"));
|
||||
const sel = /** @type {HTMLSelectElement} */ ($("#nd-type"));
|
||||
const nameInput = /** @type {HTMLInputElement} */ ($("#nd-name"));
|
||||
const err = $("#nd-error");
|
||||
showError(err, "");
|
||||
form.reset();
|
||||
|
||||
// Build the dropdown: <optgroup label="kind"> for built-ins grouped by
|
||||
// their `kind`, then project-custom, then a "Custom (no type)" option.
|
||||
sel.innerHTML = "";
|
||||
const builtIns = state.deviceTypes.filter((t) => t.built_in);
|
||||
const customs = state.deviceTypes.filter((t) => !t.built_in);
|
||||
|
||||
const byKind = new Map();
|
||||
for (const t of builtIns) {
|
||||
const k = t.kind || "generic";
|
||||
const arr = byKind.get(k) || [];
|
||||
arr.push(t);
|
||||
byKind.set(k, arr);
|
||||
}
|
||||
for (const [kind, arr] of byKind) {
|
||||
const og = document.createElement("optgroup");
|
||||
og.label = kind;
|
||||
for (const t of arr) {
|
||||
const opt = new Option(t.name, String(t.id));
|
||||
og.append(opt);
|
||||
}
|
||||
sel.append(og);
|
||||
}
|
||||
if (customs.length) {
|
||||
const og = document.createElement("optgroup");
|
||||
og.label = "custom";
|
||||
for (const t of customs) {
|
||||
og.append(new Option(t.name, String(t.id)));
|
||||
}
|
||||
sel.append(og);
|
||||
}
|
||||
const customOpt = new Option("Custom (no type)", "");
|
||||
sel.append(customOpt);
|
||||
|
||||
// Default to the first built-in (NAS in m's catalog) so m sees a
|
||||
// sensible first option. Auto-fill the name to match.
|
||||
sel.value = builtIns[0] ? String(builtIns[0].id) : "";
|
||||
syncNameToType();
|
||||
|
||||
sel.onchange = syncNameToType;
|
||||
|
||||
function syncNameToType() {
|
||||
const idStr = sel.value;
|
||||
if (!idStr) { nameInput.value = ""; return; }
|
||||
const t = state.deviceTypes.find((x) => String(x.id) === idStr);
|
||||
if (!t) return;
|
||||
nameInput.value = nextNameFor(t.name);
|
||||
}
|
||||
|
||||
dlg.showModal();
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) { showError(err, "Name is required"); return; }
|
||||
const idStr = sel.value;
|
||||
const body = {
|
||||
name,
|
||||
x: geom.x, y: geom.y, width: geom.width, height: geom.height,
|
||||
};
|
||||
if (geom.frame_id != null) body.frame_id = geom.frame_id;
|
||||
if (idStr) body.type_id = Number(idStr);
|
||||
try {
|
||||
const d = await createDevice(state.active.id, body);
|
||||
state.devices.push(d);
|
||||
// Re-fetch ports for the project — the server seeded them in the
|
||||
// same transaction, so they're already in the DB.
|
||||
const snap = await getSnapshot(state.active.id);
|
||||
state.ports = snap.ports || [];
|
||||
state.selection = { kind: "device", id: d.id };
|
||||
dlg.close();
|
||||
render();
|
||||
} catch (e) {
|
||||
showError(err, e.message || "Create failed");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function placeIOMarkerAt(p) {
|
||||
@@ -1053,6 +1215,7 @@ async function boot() {
|
||||
bindCloseButtons($("#modal-new-project"));
|
||||
bindCloseButtons($("#modal-cable-type"));
|
||||
bindCloseButtons($("#modal-delete-project"));
|
||||
bindCloseButtons($("#modal-new-device"));
|
||||
|
||||
$("#btn-new-project").addEventListener("click", openNewProjectModal);
|
||||
$("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));
|
||||
|
||||
@@ -238,6 +238,34 @@ body {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Ports — small circles laid out along the device edge. The fill is
|
||||
white so the port is visible regardless of the underlying device's
|
||||
stroke; the stroke colour comes from the cable_type the port carries
|
||||
(set inline in JS). */
|
||||
.port-circle {
|
||||
fill: #fff;
|
||||
stroke: var(--text);
|
||||
stroke-width: 2;
|
||||
pointer-events: none; /* slice 4 — selection happens at device-level */
|
||||
}
|
||||
|
||||
.port-row {
|
||||
display: grid;
|
||||
grid-template-columns: 14px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.port-row .swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.port-row .label { color: var(--text); }
|
||||
.port-row .conn { color: var(--text-muted); font-size: 11px; }
|
||||
|
||||
.rubber-band {
|
||||
fill: rgba(25, 113, 194, 0.08);
|
||||
stroke: var(--accent);
|
||||
|
||||
Reference in New Issue
Block a user