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:
192
internal/db/connection_requirements.go
Normal file
192
internal/db/connection_requirements.go
Normal 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
|
||||
}
|
||||
181
internal/db/connection_requirements_test.go
Normal file
181
internal/db/connection_requirements_test.go
Normal 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: ¬es,
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
34
internal/db/migrations/003_connection_requirements.sql
Normal file
34
internal/db/migrations/003_connection_requirements.sql
Normal 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);
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
115
internal/server/connection_requirements.go
Normal file
115
internal/server/connection_requirements.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user