merge: slice 5 — connection requirements CRUD + UI

picasso shipped (3 commits @ 6b830a5):
- migration 003: connection_requirements (pair_lo/pair_hi normalisation,
  UNIQUE on the unordered pair + cable_type), plus cables.auto column
  for the slice-6 solver
- store + handlers: full CRUD under /api/projects/:pid/connection-requirements
- frontend: Requirements sidebar section, +Requirement modal (device-pair
  autocomplete + cable-type picker + must/nice toggle), drag-A-to-B
  gesture pre-fills the modal, inspector for selected device lists its
  requirements
This commit is contained in:
mAi
2026-05-16 00:43:45 +02:00
10 changed files with 969 additions and 23 deletions

View File

@@ -0,0 +1,192 @@
package db
import (
"database/sql"
"errors"
"fmt"
)
// ConnectionRequirementCreate is the create-shape. Server normalises
// from/to into (pair_lo, pair_hi) so (A,B,T) and (B,A,T) collide.
type ConnectionRequirementCreate struct {
FromDeviceID int64
ToDeviceID int64
PreferredCableTypeID *int64
MustConnect *bool // pointer so "absent" defaults to true
Notes string
}
// ConnectionRequirementUpdate is the partial-update shape. project_id +
// the device pair are immutable post-create (changing either is best
// modelled as delete-then-create — keeps pair_lo/pair_hi semantics simple).
type ConnectionRequirementUpdate struct {
PreferredCableTypeID FrameRef // tri-state: leave / set / clear
MustConnect *bool
Notes *string
}
// CreateConnectionRequirement inserts a new requirement. Validates that
// both devices live in projectID, that from != to, and that the
// (project, pair_lo, pair_hi, preferred_cable_type_id) tuple is unique.
func (s *Store) CreateConnectionRequirement(projectID int64, r ConnectionRequirementCreate) (*ConnectionRequirement, error) {
if r.FromDeviceID == r.ToDeviceID {
return nil, fmt.Errorf("%w: from_device_id and to_device_id must differ", ErrInvalidInput)
}
if _, err := s.GetProject(projectID); err != nil {
return nil, err
}
if _, err := s.GetDevice(projectID, r.FromDeviceID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: from_device_id %d not in project %d", ErrInvalidInput, r.FromDeviceID, projectID)
}
return nil, err
}
if _, err := s.GetDevice(projectID, r.ToDeviceID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: to_device_id %d not in project %d", ErrInvalidInput, r.ToDeviceID, projectID)
}
return nil, err
}
if r.PreferredCableTypeID != nil {
if _, err := s.GetCableType(*r.PreferredCableTypeID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: preferred_cable_type_id %d not found", ErrInvalidInput, *r.PreferredCableTypeID)
}
return nil, err
}
}
must := true
if r.MustConnect != nil {
must = *r.MustConnect
}
mustInt := 0
if must {
mustInt = 1
}
lo, hi := r.FromDeviceID, r.ToDeviceID
if lo > hi {
lo, hi = hi, lo
}
res, err := s.db.Exec(
`INSERT INTO connection_requirements
(project_id, from_device_id, to_device_id, preferred_cable_type_id,
must_connect, notes, pair_lo, pair_hi)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
projectID, r.FromDeviceID, r.ToDeviceID, nullableInt64(r.PreferredCableTypeID),
mustInt, r.Notes, lo, hi,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
return s.GetConnectionRequirement(projectID, id)
}
// GetConnectionRequirement loads one by id, project-scoped.
func (s *Store) GetConnectionRequirement(projectID, id int64) (*ConnectionRequirement, error) {
var r ConnectionRequirement
var ct sql.NullInt64
var must int
err := s.db.QueryRow(
`SELECT id, project_id, from_device_id, to_device_id, preferred_cable_type_id,
must_connect, notes, created_at, updated_at
FROM connection_requirements WHERE id = ? AND project_id = ?`, id, projectID,
).Scan(&r.ID, &r.ProjectID, &r.FromDeviceID, &r.ToDeviceID, &ct,
&must, &r.Notes, &r.CreatedAt, &r.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if ct.Valid {
v := ct.Int64
r.PreferredCableTypeID = &v
}
r.MustConnect = must != 0
return &r, nil
}
// ListConnectionRequirements returns every requirement in a project,
// ordered by id (insertion order).
func (s *Store) ListConnectionRequirements(projectID int64) ([]ConnectionRequirement, error) {
rows, err := s.db.Query(
`SELECT id, project_id, from_device_id, to_device_id, preferred_cable_type_id,
must_connect, notes, created_at, updated_at
FROM connection_requirements WHERE project_id = ? ORDER BY id`, projectID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ConnectionRequirement{}
for rows.Next() {
var r ConnectionRequirement
var ct sql.NullInt64
var must int
if err := rows.Scan(&r.ID, &r.ProjectID, &r.FromDeviceID, &r.ToDeviceID, &ct,
&must, &r.Notes, &r.CreatedAt, &r.UpdatedAt); err != nil {
return nil, err
}
if ct.Valid {
v := ct.Int64
r.PreferredCableTypeID = &v
}
r.MustConnect = must != 0
out = append(out, r)
}
return out, rows.Err()
}
// UpdateConnectionRequirement applies a partial update. preferred_cable_type_id
// uses the FrameRef tri-state; must_connect + notes are plain pointers.
// The (from, to) pair is immutable on PATCH — delete + recreate to change.
func (s *Store) UpdateConnectionRequirement(projectID, id int64, u ConnectionRequirementUpdate) (*ConnectionRequirement, error) {
cur, err := s.GetConnectionRequirement(projectID, id)
if err != nil {
return nil, err
}
if u.PreferredCableTypeID.Set {
if u.PreferredCableTypeID.ID != nil {
if _, err := s.GetCableType(*u.PreferredCableTypeID.ID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: preferred_cable_type_id %d not found", ErrInvalidInput, *u.PreferredCableTypeID.ID)
}
return nil, err
}
}
cur.PreferredCableTypeID = u.PreferredCableTypeID.ID
}
if u.MustConnect != nil {
cur.MustConnect = *u.MustConnect
}
if u.Notes != nil {
cur.Notes = *u.Notes
}
mustInt := 0
if cur.MustConnect {
mustInt = 1
}
if _, err := s.db.Exec(
`UPDATE connection_requirements
SET preferred_cable_type_id = ?, must_connect = ?, notes = ?, updated_at = datetime('now')
WHERE id = ? AND project_id = ?`,
nullableInt64(cur.PreferredCableTypeID), mustInt, cur.Notes, id, projectID,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetConnectionRequirement(projectID, id)
}
// DeleteConnectionRequirement removes a requirement by id, project-scoped.
func (s *Store) DeleteConnectionRequirement(projectID, id int64) error {
if _, err := s.GetConnectionRequirement(projectID, id); err != nil {
return err
}
if _, err := s.db.Exec(
`DELETE FROM connection_requirements WHERE id = ? AND project_id = ?`, id, projectID,
); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,181 @@
package db
import (
"errors"
"testing"
)
func setupTwoDevices(t *testing.T, s *Store) (int64, int64, int64) {
t.Helper()
p, _ := s.CreateProject("LOFT", "", "")
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "NAS", X: 0, Y: 0, Width: 100, Height: 35})
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Switch", X: 200, Y: 0, Width: 100, Height: 35})
return p.ID, a.ID, b.ID
}
func TestCreateConnReq_Basic(t *testing.T) {
s := newTestStore(t)
pid, a, b := setupTwoDevices(t, s)
rj45 := int64(5)
r, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &rj45,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if !r.MustConnect {
t.Errorf("must_connect default should be true")
}
if r.PreferredCableTypeID == nil || *r.PreferredCableTypeID != rj45 {
t.Errorf("preferred_cable_type_id wrong: %+v", r.PreferredCableTypeID)
}
}
func TestCreateConnReq_PairNormalisationRejectsReverse(t *testing.T) {
s := newTestStore(t)
pid, a, b := setupTwoDevices(t, s)
rj45 := int64(5)
if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &rj45,
}); err != nil {
t.Fatalf("first: %v", err)
}
// (B, A, RJ45) should collide on UNIQUE (pair_lo, pair_hi, type).
_, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: b, ToDeviceID: a, PreferredCableTypeID: &rj45,
})
if !errors.Is(err, ErrConflict) {
t.Errorf("reverse pair err = %v, want ErrConflict", err)
}
}
func TestCreateConnReq_DifferentCableTypesCoexist(t *testing.T) {
s := newTestStore(t)
pid, a, b := setupTwoDevices(t, s)
rj45, power := int64(5), int64(1)
if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &rj45,
}); err != nil {
t.Fatalf("rj45: %v", err)
}
if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &power,
}); err != nil {
t.Errorf("power on same pair should be allowed: %v", err)
}
}
func TestCreateConnReq_SelfLoopRejected(t *testing.T) {
s := newTestStore(t)
pid, a, _ := setupTwoDevices(t, s)
_, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: a,
})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("self-loop err = %v, want ErrInvalidInput", err)
}
}
func TestCreateConnReq_CrossProjectDeviceRejected(t *testing.T) {
s := newTestStore(t)
pid, a, _ := setupTwoDevices(t, s)
p2, _ := s.CreateProject("OFFICE", "", "")
b2, _ := s.CreateDevice(p2.ID, DeviceCreate{Name: "X", X: 0, Y: 0, Width: 100, Height: 35})
_, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: b2.ID,
})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("cross-project to-device err = %v, want ErrInvalidInput", err)
}
}
func TestCreateConnReq_NullCableTypeUniqueByPair(t *testing.T) {
// Two NULL-cable-type reqs on the same pair are NOT a conflict in
// SQLite (NULL != NULL in UNIQUE comparisons). This is fine — they
// represent "solver picks" both times; the second wins when solving.
s := newTestStore(t)
pid, a, b := setupTwoDevices(t, s)
if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: b,
}); err != nil {
t.Fatalf("first: %v", err)
}
if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: b,
}); err != nil {
t.Errorf("second NULL-type req should be allowed (SQLite NULL != NULL): %v", err)
}
}
func TestUpdateConnReq_PartialFields(t *testing.T) {
s := newTestStore(t)
pid, a, b := setupTwoDevices(t, s)
rj45, power := int64(5), int64(1)
r, _ := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &rj45,
})
notes := "important"
must := false
updated, err := s.UpdateConnectionRequirement(pid, r.ID, ConnectionRequirementUpdate{
PreferredCableTypeID: FrameRef{Set: true, ID: &power},
MustConnect: &must,
Notes: &notes,
})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.PreferredCableTypeID == nil || *updated.PreferredCableTypeID != power {
t.Errorf("cable type not switched: %+v", updated.PreferredCableTypeID)
}
if updated.MustConnect {
t.Errorf("must_connect should be false")
}
if updated.Notes != "important" {
t.Errorf("notes = %q", updated.Notes)
}
// Clear the cable type.
cleared, _ := s.UpdateConnectionRequirement(pid, r.ID, ConnectionRequirementUpdate{
PreferredCableTypeID: FrameRef{Set: true, ID: nil},
})
if cleared.PreferredCableTypeID != nil {
t.Errorf("preferred_cable_type_id should be nil after clear; got %v", *cleared.PreferredCableTypeID)
}
}
func TestDeleteConnReq_CascadesOnDeviceDelete(t *testing.T) {
s := newTestStore(t)
pid, a, b := setupTwoDevices(t, s)
r, _ := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: b,
})
if err := s.DeleteDevice(pid, a); err != nil {
t.Fatalf("delete device a: %v", err)
}
if _, err := s.GetConnectionRequirement(pid, r.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("requirement should be gone after device delete; got %v", err)
}
}
func TestSnapshot_IncludesConnectionRequirements(t *testing.T) {
s := newTestStore(t)
pid, a, b := setupTwoDevices(t, s)
_, _ = s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: b,
})
snap, err := s.Snapshot(pid)
if err != nil {
t.Fatalf("snapshot: %v", err)
}
if len(snap.ConnectionRequirements) != 1 {
t.Errorf("snapshot.connection_requirements = %d, want 1", len(snap.ConnectionRequirements))
}
}
func TestDeleteConnReq_NotFound(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if err := s.DeleteConnectionRequirement(p.ID, 999); !errors.Is(err, ErrNotFound) {
t.Errorf("got %v, want ErrNotFound", err)
}
}

View File

@@ -0,0 +1,34 @@
-- mCables v4.1 connection requirements + solver-owned cable flag.
-- See docs/design.md §2.1 + §2 connection_requirements + §5b.3.
-- The solver's input: "device A must connect to device B via cable type T".
-- Many per device. (from, to) is normalised on insert as
-- (pair_lo, pair_hi) = (MIN(from, to), MAX(from, to)) so (A,B,T) and (B,A,T)
-- can't coexist (UNIQUE enforces it).
CREATE TABLE connection_requirements (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
from_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
to_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
preferred_cable_type_id INTEGER REFERENCES cable_types(id) ON DELETE SET NULL,
must_connect INTEGER NOT NULL DEFAULT 1 CHECK (must_connect IN (0, 1)),
notes TEXT NOT NULL DEFAULT '',
pair_lo INTEGER NOT NULL,
pair_hi INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (from_device_id != to_device_id),
UNIQUE (project_id, pair_lo, pair_hi, preferred_cable_type_id)
);
CREATE INDEX conn_reqs_project_idx ON connection_requirements(project_id);
CREATE INDEX conn_reqs_pair_idx ON connection_requirements(project_id, pair_lo, pair_hi);
CREATE INDEX conn_reqs_from_idx ON connection_requirements(from_device_id);
CREATE INDEX conn_reqs_to_idx ON connection_requirements(to_device_id);
-- Solver-owned cable flag (§5b.3): 1 = the solver placed this cable,
-- replaceable on re-solve. 0 = m hand-drew it, left alone by the solver.
-- Slice 6 ships the solver that writes auto=1; slice 7 ships hand-drawn
-- cable creation that writes auto=0.
ALTER TABLE cables ADD COLUMN auto INTEGER NOT NULL DEFAULT 0
CHECK (auto IN (0, 1));
CREATE INDEX cables_auto_idx ON cables(auto);

View File

@@ -95,16 +95,33 @@ type Port struct {
UpdatedAt string `json:"updated_at"`
}
// ConnectionRequirement is the solver's per-project input.
// pair_lo/pair_hi are the ordered (MIN,MAX) of (from, to) so the
// UNIQUE on (project_id, pair_lo, pair_hi, preferred_cable_type_id)
// prevents (A,B,T) AND (B,A,T) from coexisting.
type ConnectionRequirement struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
FromDeviceID int64 `json:"from_device_id"`
ToDeviceID int64 `json:"to_device_id"`
PreferredCableTypeID *int64 `json:"preferred_cable_type_id"`
MustConnect bool `json:"must_connect"`
Notes string `json:"notes"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Snapshot is the editor's one-shot loader payload for a single project.
// Arrays for collections still gated by future slices stay non-nil [] so
// JSON encodes as [] not null.
type Snapshot struct {
Project Project `json:"project"`
Frames []Frame `json:"frames"`
Devices []Device `json:"devices"`
Ports []Port `json:"ports"`
Cables []any `json:"cables"`
IOMarkers []IOMarker `json:"io_markers"`
Bundles []any `json:"bundles"`
CableTypes []CableType `json:"cable_types"`
Project Project `json:"project"`
Frames []Frame `json:"frames"`
Devices []Device `json:"devices"`
Ports []Port `json:"ports"`
Cables []any `json:"cables"`
IOMarkers []IOMarker `json:"io_markers"`
Bundles []any `json:"bundles"`
CableTypes []CableType `json:"cable_types"`
ConnectionRequirements []ConnectionRequirement `json:"connection_requirements"`
}

View File

@@ -175,15 +175,20 @@ func (s *Store) Snapshot(id int64) (*Snapshot, error) {
if err != nil {
return nil, err
}
reqs, err := s.ListConnectionRequirements(id)
if err != nil {
return nil, err
}
return &Snapshot{
Project: *p,
Frames: frames,
Devices: devices,
Ports: ports,
Cables: []any{},
IOMarkers: ios,
Bundles: []any{},
CableTypes: types,
Project: *p,
Frames: frames,
Devices: devices,
Ports: ports,
Cables: []any{},
IOMarkers: ios,
Bundles: []any{},
CableTypes: types,
ConnectionRequirements: reqs,
}, nil
}

View File

@@ -0,0 +1,115 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/mcables/internal/db"
)
type connReqCreate struct {
FromDeviceID int64 `json:"from_device_id"`
ToDeviceID int64 `json:"to_device_id"`
PreferredCableTypeID *int64 `json:"preferred_cable_type_id,omitempty"`
MustConnect *bool `json:"must_connect,omitempty"`
Notes string `json:"notes,omitempty"`
}
// connReqPatch uses RawMessage for preferred_cable_type_id so the wire
// tri-state ({} / null / int) is preserved.
type connReqPatch struct {
PreferredCableTypeID json.RawMessage `json:"preferred_cable_type_id,omitempty"`
MustConnect *bool `json:"must_connect,omitempty"`
Notes *string `json:"notes,omitempty"`
}
func (h *handlers) listConnectionRequirements(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
rs, err := h.store.ListConnectionRequirements(pid)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, rs)
}
func (h *handlers) createConnectionRequirement(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
var body connReqCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
cr, err := h.store.CreateConnectionRequirement(pid, db.ConnectionRequirementCreate{
FromDeviceID: body.FromDeviceID,
ToDeviceID: body.ToDeviceID,
PreferredCableTypeID: body.PreferredCableTypeID,
MustConnect: body.MustConnect,
Notes: body.Notes,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, cr)
}
func (h *handlers) patchConnectionRequirement(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
var body connReqPatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
ctRef, err := parseFrameRef(body.PreferredCableTypeID)
if err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), "preferred_cable_type_id must be an integer or null")
return
}
cr, err := h.store.UpdateConnectionRequirement(pid, id, db.ConnectionRequirementUpdate{
PreferredCableTypeID: ctRef,
MustConnect: body.MustConnect,
Notes: body.Notes,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, cr)
}
func (h *handlers) deleteConnectionRequirement(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
if err := h.store.DeleteConnectionRequirement(pid, id); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -59,6 +59,12 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
mux.HandleFunc("PATCH /api/projects/{pid}/device-types/{id}", h.patchDeviceType)
mux.HandleFunc("DELETE /api/projects/{pid}/device-types/{id}", h.deleteDeviceType)
// Connection requirements — the solver's per-project input.
mux.HandleFunc("GET /api/projects/{pid}/connection-requirements", h.listConnectionRequirements)
mux.HandleFunc("POST /api/projects/{pid}/connection-requirements", h.createConnectionRequirement)
mux.HandleFunc("PATCH /api/projects/{pid}/connection-requirements/{id}", h.patchConnectionRequirement)
mux.HandleFunc("DELETE /api/projects/{pid}/connection-requirements/{id}", h.deleteConnectionRequirement)
// Frontend (embedded). Serve "/" → index.html via http.FileServerFS.
// Wrap in noCache so the browser revalidates with the ETag/Last-Modified
// the file server already emits — without this, browsers cache aggressively

View File

@@ -32,12 +32,18 @@
<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">
<li><button type="button" id="tool-frame" class="btn btn-tiny" data-tool="frame">+ Frame</button></li>
<li><button type="button" id="tool-device" class="btn btn-tiny" data-tool="device">+ Device</button></li>
<li><button type="button" id="tool-io" class="btn btn-tiny" data-tool="io">+ IO</button></li>
<li><button type="button" id="tool-req" class="btn btn-tiny" data-tool="req">Drag req A→B</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 7">Draw cable</button></li>
</ul>
</section>
@@ -135,6 +141,40 @@
</form>
</dialog>
<!-- New / Edit connection requirement (slice 5) -->
<dialog id="modal-requirement" class="modal" aria-labelledby="rq-title">
<form method="dialog" id="form-requirement">
<h2 id="rq-title">New requirement</h2>
<label class="field">
<span>From device</span>
<select id="rq-from" name="from_device_id" required></select>
</label>
<label class="field">
<span>To device</span>
<select id="rq-to" name="to_device_id" required></select>
</label>
<label class="field">
<span>Cable type</span>
<select id="rq-cable" name="preferred_cable_type_id">
<option value="">— solver picks —</option>
</select>
</label>
<label class="field" style="flex-direction: row; align-items: center; gap: 8px;">
<input type="checkbox" id="rq-must" name="must_connect" checked />
<span style="font-size: 13px; color: var(--text);">Must connect (solver hard-requires this link)</span>
</label>
<label class="field">
<span>Notes</span>
<textarea name="notes" rows="2"></textarea>
</label>
<p class="form-error" id="rq-error" hidden></p>
<div class="actions">
<button type="submit" class="btn btn-primary">Save</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">

View File

@@ -25,6 +25,9 @@
* @typedef {{ id: number, project_id: number|null, name: string,
* kind: string, icon: string|null, description: string,
* built_in: boolean, ports: DeviceTypePort[] }} DeviceType
* @typedef {{ id: number, project_id: number, from_device_id: number,
* to_device_id: number, preferred_cable_type_id: number|null,
* must_connect: boolean, notes: string }} ConnectionRequirement
*/
const API = "/api";
@@ -40,10 +43,11 @@ const state = {
/** @type {Device[]} */ devices: [],
/** @type {Port[]} */ ports: [],
/** @type {IOMarker[]} */ ioMarkers: [],
/** @type {ConnectionRequirement[]} */ requirements: [],
activeTypeId: /** @type {number|null} */ (null),
/** "frame" | "device" | "io" | null */
/** "frame" | "device" | "io" | "req" | null */
tool: /** @type {string|null} */ (null),
/** @type {{kind: "frame"|"device"|"io"|"cable_type", id: number} | null} */ selection: null,
/** @type {{kind: "frame"|"device"|"io"|"cable_type"|"requirement", id: number} | null} */ selection: null,
};
// ---------- API client ---------- //
@@ -91,6 +95,10 @@ const deleteIOMarker = (pid, id) => api("DELETE", `/projects/${pid}/io-markers/$
const listDeviceTypesForProject = (pid) => api("GET", `/projects/${pid}/device-types`);
const createRequirement = (pid, body) => api("POST", `/projects/${pid}/connection-requirements`, body);
const patchRequirement = (pid, id, body) => api("PATCH", `/projects/${pid}/connection-requirements/${id}`, body);
const deleteRequirement = (pid, id) => api("DELETE", `/projects/${pid}/connection-requirements/${id}`);
// ---------- DOM helpers ---------- //
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
@@ -323,9 +331,10 @@ function renderInspector() {
switch (state.selection.kind) {
case "frame": return renderInspectorFrame(body, state.selection.id);
case "device": return renderInspectorDevice(body, state.selection.id);
case "io": return renderInspectorIO(body, state.selection.id);
case "cable_type": return renderInspectorCableType(body, state.selection.id);
default: body.innerHTML = `<p class="muted">Nothing selected.</p>`;
case "io": return renderInspectorIO(body, state.selection.id);
case "cable_type": return renderInspectorCableType(body, state.selection.id);
case "requirement": return renderInspectorRequirement(body, state.selection.id);
default: body.innerHTML = `<p class="muted">Nothing selected.</p>`;
}
}
@@ -393,11 +402,34 @@ function renderInspectorDevice(body, id) {
? 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="label">${escapeHtml(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>`;
// Requirements involving this device — sorted as (other-device-name asc).
const involved = state.requirements.filter((r) => r.from_device_id === d.id || r.to_device_id === d.id);
const deviceById = new Map(state.devices.map((x) => [x.id, x]));
involved.sort((a, b) => {
const oa = (a.from_device_id === d.id ? a.to_device_id : a.from_device_id);
const ob = (b.from_device_id === d.id ? b.to_device_id : b.from_device_id);
return (deviceById.get(oa)?.name || "").localeCompare(deviceById.get(ob)?.name || "");
});
const reqsHtml = involved.length
? involved.map((r) => {
const other = (r.from_device_id === d.id ? r.to_device_id : r.from_device_id);
const otherName = deviceById.get(other)?.name ?? `device #${other}`;
const ct = r.preferred_cable_type_id != null ? cableTypeName.get(r.preferred_cable_type_id) : null;
return `
<div class="requirement-row" data-req-id="${r.id}">
<span class="pair">↔ ${escapeHtml(otherName)}
<span class="type"> · ${escapeHtml(ct ?? "solver picks")}</span>
</span>
<span class="badge ${r.must_connect ? "must" : "nice"}">${r.must_connect ? "must" : "nice"}</span>
</div>`;
}).join("")
: `<p class="muted" style="font-size:12px">No requirements yet.</p>`;
body.innerHTML = `
<p class="section-title">Device</p>
<label class="field">
@@ -418,6 +450,8 @@ function renderInspectorDevice(body, id) {
</dl>
<p class="section-title">Ports</p>
<div id="dev-ports">${portsHtml}</div>
<p class="section-title">Requirements</p>
<div id="dev-reqs">${reqsHtml}</div>
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="dev-delete">Delete device</button>
</div>
@@ -460,10 +494,224 @@ function renderInspectorDevice(body, id) {
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);
// Server cascaded the requirements; drop them locally too.
state.requirements = state.requirements.filter(
(r) => r.from_device_id !== d.id && r.to_device_id !== d.id,
);
state.selection = null;
render();
}).catch((e) => alert(`Delete failed: ${e.message}`));
});
// Clicking a requirement row in the device inspector jumps to that
// requirement's own inspector pane.
body.querySelectorAll("[data-req-id]").forEach((el) => {
el.addEventListener("click", () => {
const rid = Number(el.getAttribute("data-req-id"));
state.selection = { kind: "requirement", id: rid };
render();
});
});
}
function renderInspectorRequirement(body, id) {
const r = state.requirements.find((x) => x.id === id);
if (!r) { body.innerHTML = ""; return; }
const deviceById = new Map(state.devices.map((d) => [d.id, d]));
const a = deviceById.get(r.from_device_id);
const b = deviceById.get(r.to_device_id);
const ctName = r.preferred_cable_type_id != null
? state.cableTypes.find((t) => t.id === r.preferred_cable_type_id)?.name
: null;
body.innerHTML = `
<p class="section-title">Connection requirement</p>
<dl>
<dt>from</dt><dd id="rq-from-name"></dd>
<dt>to</dt><dd id="rq-to-name"></dd>
<dt>cable</dt><dd id="rq-ct"></dd>
<dt>type</dt><dd id="rq-must">${r.must_connect ? "must connect" : "nice to have"}</dd>
</dl>
<label class="field">
<span>Notes</span>
<textarea class="inline-input" id="rq-notes" rows="2"></textarea>
</label>
<div class="inspector-actions">
<button type="button" class="btn btn-tiny" id="rq-edit">Edit</button>
<button type="button" class="btn btn-tiny" id="rq-toggle">${r.must_connect ? "Make nice" : "Make must"}</button>
<button type="button" class="btn btn-danger btn-tiny" id="rq-del">Delete</button>
</div>
`;
body.querySelector("#rq-from-name").textContent = a ? a.name : `#${r.from_device_id}`;
body.querySelector("#rq-to-name").textContent = b ? b.name : `#${r.to_device_id}`;
body.querySelector("#rq-ct").textContent = ctName ?? "solver picks";
body.querySelector("#rq-notes").value = r.notes ?? "";
bindDebouncedRename(body.querySelector("#rq-notes"), async (notes) => {
if (!state.active) return;
const updated = await patchRequirement(state.active.id, r.id, { notes });
Object.assign(r, updated);
renderRequirements();
});
body.querySelector("#rq-edit").addEventListener("click", () => openRequirementModal(r));
body.querySelector("#rq-toggle").addEventListener("click", async () => {
if (!state.active) return;
try {
const updated = await patchRequirement(state.active.id, r.id, { must_connect: !r.must_connect });
Object.assign(r, updated);
render();
} catch (e) { alert(`Update failed: ${e.message}`); }
});
body.querySelector("#rq-del").addEventListener("click", async () => {
if (!state.active) return;
if (!confirm("Delete this requirement?")) return;
try {
await deleteRequirement(state.active.id, r.id);
state.requirements = state.requirements.filter((x) => x.id !== r.id);
state.selection = null;
render();
} catch (e) { alert(`Delete failed: ${e.message}`); }
});
}
// ---------- requirement drag gesture ---------- //
/** Pointerdown on a device with `req` tool armed → draw a dashed line to
* the pointer position. Pointerup on another device opens the modal
* with from/to pre-filled. Anywhere else cancels. */
function startRequirementDrag(e, fromDeviceID) {
if (!state.active) return;
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const fromDev = state.devices.find((d) => d.id === fromDeviceID);
if (!fromDev) return;
const sx = fromDev.x + fromDev.width / 2;
const sy = fromDev.y + fromDev.height / 2;
const line = svgEl("line", {
x1: sx, y1: sy, x2: sx, y2: sy,
class: "req-drag-line",
});
svg.append(line);
svg.setPointerCapture(e.pointerId);
const onMove = (ev) => {
const p = svgPoint(ev);
line.setAttribute("x2", String(p.x));
line.setAttribute("y2", String(p.y));
};
const onUp = (ev) => {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.releasePointerCapture(e.pointerId);
line.remove();
// Hit-test: which device did the pointer land on?
let toDeviceID = null;
if (ev.target instanceof Element) {
const g = ev.target.closest("[data-device-id]");
if (g) toDeviceID = Number(g.getAttribute("data-device-id"));
}
armTool(null);
if (!toDeviceID || toDeviceID === fromDeviceID) return; // cancel
openRequirementModal(null, { from: fromDeviceID, to: toDeviceID });
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
}
// ---------- requirement modal ---------- //
/**
* Open the +Requirement / edit modal. Pass `existing` to edit an existing
* row; pass `{from, to}` (device ids, both optional) to pre-fill a new row.
*/
function openRequirementModal(existing, prefill = {}) {
if (!state.active) return;
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-requirement"));
const form = /** @type {HTMLFormElement} */ ($("#form-requirement"));
const selFrom = /** @type {HTMLSelectElement} */ ($("#rq-from"));
const selTo = /** @type {HTMLSelectElement} */ ($("#rq-to"));
const selCt = /** @type {HTMLSelectElement} */ ($("#rq-cable"));
const mustCb = /** @type {HTMLInputElement} */ ($("#rq-must"));
const err = $("#rq-error");
const title = $("#rq-title");
showError(err, "");
title.textContent = existing ? "Edit requirement" : "New requirement";
// Populate the device pickers.
for (const sel of [selFrom, selTo]) {
sel.innerHTML = "";
for (const d of state.devices) {
sel.append(new Option(d.name, String(d.id)));
}
}
// Cable-type picker: "solver picks" + every cable type.
selCt.innerHTML = "";
selCt.append(new Option("— solver picks —", ""));
for (const ct of state.cableTypes) {
selCt.append(new Option(ct.name, String(ct.id)));
}
if (existing) {
selFrom.value = String(existing.from_device_id);
selTo.value = String(existing.to_device_id);
selCt.value = existing.preferred_cable_type_id != null ? String(existing.preferred_cable_type_id) : "";
mustCb.checked = existing.must_connect;
form.elements.namedItem("notes").value = existing.notes || "";
} else {
if (prefill.from != null) selFrom.value = String(prefill.from);
if (prefill.to != null) selTo.value = String(prefill.to);
if (selFrom.value === selTo.value && state.devices.length >= 2) {
// Pick a different "to" so the form starts valid.
const other = state.devices.find((d) => String(d.id) !== selFrom.value);
if (other) selTo.value = String(other.id);
}
selCt.value = "";
mustCb.checked = true;
form.elements.namedItem("notes").value = "";
}
dlg.showModal();
form.onsubmit = async (e) => {
e.preventDefault();
const fromID = Number(selFrom.value);
const toID = Number(selTo.value);
if (!fromID || !toID || fromID === toID) {
showError(err, "from and to must be two different devices");
return;
}
const ctRaw = selCt.value;
const notes = String(form.elements.namedItem("notes").value || "");
const must = mustCb.checked;
try {
if (existing) {
const body = {
must_connect: must,
notes,
// tri-state: empty string → null on the wire (= clear)
preferred_cable_type_id: ctRaw === "" ? null : Number(ctRaw),
};
const updated = await patchRequirement(state.active.id, existing.id, body);
Object.assign(existing, updated);
} else {
const body = {
from_device_id: fromID,
to_device_id: toID,
must_connect: must,
notes,
};
if (ctRaw !== "") body.preferred_cable_type_id = Number(ctRaw);
const created = await createRequirement(state.active.id, body);
state.requirements.push(created);
state.selection = { kind: "requirement", id: created.id };
}
dlg.close();
render();
} catch (ex) {
showError(err, ex.message || "Save failed");
}
};
}
function renderInspectorIO(body, id) {
@@ -602,11 +850,52 @@ 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;",
}[c]));
}
// ---------- active project ---------- //
async function activateProject(id) {
@@ -616,6 +905,7 @@ async function activateProject(id) {
state.devices = [];
state.ports = [];
state.ioMarkers = [];
state.requirements = [];
state.selection = null;
setActiveInURL(null);
render();
@@ -628,6 +918,7 @@ async function activateProject(id) {
state.devices = snap.devices || [];
state.ioMarkers = snap.io_markers || [];
state.ports = snap.ports || [];
state.requirements = snap.connection_requirements || [];
state.cableTypes = snap.cable_types || [];
state.selection = null;
setActiveInURL(id);
@@ -649,6 +940,7 @@ async function activateProject(id) {
state.devices = [];
state.ports = [];
state.ioMarkers = [];
state.requirements = [];
setActiveInURL(null);
render();
} else {
@@ -683,6 +975,7 @@ function bindTools() {
else if (e.key === "f" || e.key === "F") armTool("frame");
else if (e.key === "d" || e.key === "D") armTool("device");
else if (e.key === "i" || e.key === "I") armTool("io");
else if (e.key === "r" || e.key === "R") armTool("req");
});
// Canvas-level pointerdown handles tool activation + selection clearing.
@@ -969,7 +1262,14 @@ function promptInline(placeholder, cx, cy) {
function startDrag(e, kind, id) {
if (!state.active) return;
if (state.tool) return; // a tool is armed; don't hijack
// Req tool intercepts device-down to start the drag-A-to-B gesture.
if (state.tool === "req" && kind === "device") {
e.stopPropagation();
e.preventDefault();
startRequirementDrag(e, id);
return;
}
if (state.tool) return; // any other tool — let the canvas-level handler run
e.stopPropagation();
state.selection = { kind, id };
// Render immediately so the inspector reflects the new selection from
@@ -1216,10 +1516,16 @@ async function boot() {
bindCloseButtons($("#modal-cable-type"));
bindCloseButtons($("#modal-delete-project"));
bindCloseButtons($("#modal-new-device"));
bindCloseButtons($("#modal-requirement"));
$("#btn-new-project").addEventListener("click", openNewProjectModal);
$("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));
$("#btn-delete-project").addEventListener("click", openDeleteProjectModal);
$("#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);
});
$("#project-select").addEventListener("change", (e) => {
const v = /** @type {HTMLSelectElement} */ (e.target).value;

View File

@@ -266,6 +266,56 @@ body {
.port-row .label { color: var(--text); }
.port-row .conn { color: var(--text-muted); font-size: 11px; }
/* Requirements sidebar list */
.requirement-list {
list-style: none;
padding: 0;
margin: 0 0 8px 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.requirement-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 6px;
font-size: 12px;
padding: 3px 6px;
border-radius: var(--radius);
cursor: pointer;
}
.requirement-row:hover { background: var(--surface-2); }
.requirement-row[aria-current="true"] {
background: var(--surface-2);
outline: 1px solid var(--accent);
}
.requirement-row .pair { color: var(--text); }
.requirement-row .pair .type { color: var(--text-muted); font-size: 11px; }
.requirement-row .badge {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 6px;
border-radius: 10px;
color: #fff;
}
.requirement-row .badge.must { background: var(--danger); }
.requirement-row .badge.nice { background: var(--text-muted); }
/* Tool-armed: drag-req tool cursor */
.canvas-wrap.tool-req #canvas,
.canvas-wrap.tool-req #canvas * { cursor: crosshair !important; }
/* Drag-line preview while dragging from device A toward device B. */
.req-drag-line {
stroke: var(--accent);
stroke-width: 2;
stroke-dasharray: 6 4;
fill: none;
pointer-events: none;
}
.rubber-band {
fill: rgba(25, 113, 194, 0.08);
stroke: var(--accent);