Add manufacturer catalog, category/manufacturer library management, and built-in cable flag

Normalizes manufacturer as a shared catalog entity (like device categories)
instead of free text on each device, giving the admin duplicate-detection
nudge a reliable signal. Adds full CRUD (including Admin direct-publish,
bypassing the submission queue) for categories, manufacturers, port types,
and cable types, plus a Categories & Manufacturers library modal and a
browse-by-manufacturer/search view in the device palette. Adds
Port.builtInCable so a captive/permanently-attached cable (a keyboard's USB
lead, a budget AVR's power cord) can be flagged and excluded from the BOM.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
This commit is contained in:
2026-09-28 10:04:05 -05:00
co-authored by Claude Sonnet 5
parent 00777077c2
commit 29c3ed46a3
28 changed files with 2201 additions and 320 deletions
+8 -1
View File
@@ -11,13 +11,20 @@ export default function CategorySelect({
value, value,
onChange, onChange,
className = 'mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm', className = 'mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm',
publishPublic = false,
}: { }: {
value: string value: string
onChange: (categoryId: string) => void onChange: (categoryId: string) => void
className?: string className?: string
/** When true, a category created here goes straight into the public
* catalog (Admin-only) instead of your own private one — used when the
* containing form is itself publishing directly, so a brand-new category
* it references doesn't end up private and invisible to everyone else. */
publishPublic?: boolean
}) { }) {
const catalog = useCatalogStore((s) => s.catalog) const catalog = useCatalogStore((s) => s.catalog)
const addCustomDeviceCategory = useCatalogStore((s) => s.addCustomDeviceCategory) const addCustomDeviceCategory = useCatalogStore((s) => s.addCustomDeviceCategory)
const adminAddDeviceCategory = useCatalogStore((s) => s.adminAddDeviceCategory)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [draftName, setDraftName] = useState('') const [draftName, setDraftName] = useState('')
@@ -29,7 +36,7 @@ export default function CategorySelect({
const commitNewCategory = async () => { const commitNewCategory = async () => {
const trimmed = draftName.trim() const trimmed = draftName.trim()
if (trimmed) { if (trimmed) {
onChange(await addCustomDeviceCategory(trimmed)) onChange(await (publishPublic ? adminAddDeviceCategory(trimmed) : addCustomDeviceCategory(trimmed)))
} }
setCreating(false) setCreating(false)
setDraftName('') setDraftName('')
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import Chevron from './Chevron'
/**
* A click-toggled dropdown menu for grouping several TopBar actions behind
* one trigger — closes on an outside click, or automatically when any menu
* item is clicked (via bubbling, so individual items don't each need their
* own "and now close the menu" call).
*/
export default function DropdownMenu({
label,
align = 'left',
children,
}: {
label: ReactNode
align?: 'left' | 'right'
children: ReactNode
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onOutsideClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onOutsideClick)
return () => document.removeEventListener('mousedown', onOutsideClick)
}, [open])
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1 rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
>
{label}
<Chevron expanded={open} className="text-slate-400" />
</button>
{open && (
<div
onClick={() => setOpen(false)}
className={`absolute top-full z-30 mt-1 min-w-[190px] rounded border border-slate-200 bg-white py-1 shadow-lg ${
align === 'right' ? 'right-0' : 'left-0'
}`}
>
{children}
</div>
)}
</div>
)
}
/** A row inside a DropdownMenu — same look everywhere so items from
* different menus (Diagram, Admin, Account) don't feel inconsistent. */
export function DropdownMenuItem({
onClick,
children,
badge,
}: {
onClick: () => void
children: ReactNode
badge?: ReactNode
}) {
return (
<button
onClick={onClick}
className="flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left text-xs text-slate-600 hover:bg-slate-100"
>
<span>{children}</span>
{badge}
</button>
)
}
+132
View File
@@ -0,0 +1,132 @@
import { useMemo, useState } from 'react'
import { familyLabel } from '../../domain/diagram'
import type { PortType } from '../../domain/types'
import InfoTooltip from './InfoTooltip'
const NEW_FAMILY_VALUE = '__new__'
const FAMILY_HELP_TEXT =
'A connector family groups together connectors that are physically and electrically identical, so the same cables fit them all with no adapter needed. Pick an existing family to match one exactly; use "+ New family…" only for a genuinely different connector. For one that mates via an adapter cable instead, use "Can accept connections from" below.'
/**
* A compatibility-family picker for defining/editing a PortType. `family` is
* an opaque matching key (two ports connect only if their families are the
* same string), which used to be a plain free-text field — impossible to
* know what to type to deliberately match an *existing* family (is it
* "HDMI", "hdmi", "Hdmi 2.1"?) without reading source. This instead lists
* every family already in use, labeled by the port type that owns it (see
* familyLabel), so matching an existing one is a pick, not a guess — with a
* "+ New family…" escape hatch (free text) for a genuinely new one, same
* inline-create shape as CategorySelect/ManufacturerSelect.
*
* Unlike those two, there's no backend entity to insert here — family is
* just a string on the PortType being saved — so "new" mode only needs a
* local draft and a plain callback, no async create step.
*/
export default function FamilySelect({
value,
onChange,
portTypes,
className = 'w-full rounded border border-slate-300 px-1.5 py-1 text-xs',
}: {
value: string
onChange: (family: string) => void
portTypes: PortType[]
className?: string
}) {
const families = useMemo(() => {
const seen = new Set<string>()
const entries: { family: string; label: string }[] = []
for (const pt of portTypes) {
if (seen.has(pt.family)) continue
seen.add(pt.family)
entries.push({ family: pt.family, label: familyLabel(portTypes, pt.family) })
}
return entries.sort((a, b) => a.label.localeCompare(b.label))
}, [portTypes])
// Defaults to the free-text box when the current value isn't one of the
// known families — covers both "brand new port type, nothing typed yet"
// (value is '') and "editing a type whose family predates this picker".
// An existing type's own family is always itself in `families` (it's one
// of `portTypes`), so this never spuriously hides a valid current value.
const [creating, setCreating] = useState(() => value === '' || !families.some((f) => f.family === value))
const [draft, setDraft] = useState(value)
const commitDraft = () => {
const trimmed = draft.trim()
if (trimmed) onChange(trimmed)
setCreating(false)
}
const label = (
<div className="mb-1 flex items-center gap-1">
<span className="text-[10px] font-medium uppercase tracking-wide text-slate-500">Family</span>
<InfoTooltip text={FAMILY_HELP_TEXT} />
</div>
)
if (creating) {
return (
<div>
{label}
<div className="flex gap-1.5">
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
commitDraft()
} else if (e.key === 'Escape' && families.length > 0) {
setCreating(false)
}
}}
placeholder="New family (e.g. hdmi)"
className={`min-w-0 flex-1 ${className}`}
/>
<button
onClick={commitDraft}
className="shrink-0 rounded bg-indigo-600 px-2 text-xs font-medium text-white hover:bg-indigo-500"
>
Use
</button>
{families.length > 0 && (
<button
onClick={() => setCreating(false)}
className="shrink-0 rounded px-2 text-xs text-slate-500 hover:bg-slate-100"
>
Choose existing
</button>
)}
</div>
</div>
)
}
return (
<div>
{label}
<select
value={value}
onChange={(e) => {
if (e.target.value === NEW_FAMILY_VALUE) {
setDraft('')
setCreating(true)
} else {
onChange(e.target.value)
}
}}
className={className}
>
{families.map((f) => (
<option key={f.family} value={f.family}>
{f.label} ({f.family})
</option>
))}
<option value={NEW_FAMILY_VALUE}>+ New family…</option>
</select>
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
const POPOVER_WIDTH = 256
const VIEWPORT_MARGIN = 8
/**
* A small "i" affordance that reveals a block of help text on click — for a
* concept that needs more than a one-line inline hint can carry, without
* permanently taking up screen space. Click-toggled rather than hover-only
* so it works the same on touch as with a mouse, and stays open long enough
* to actually read instead of vanishing the moment the pointer drifts.
*
* Renders the popover through a portal to `document.body`, positioned via
* the trigger's own bounding rect rather than as a normal absolutely-
* positioned child — this component is mostly used inside a Modal, whose
* scrollable body clips anything (including absolutely-positioned children)
* that pokes past its edge, which cut the popover off before this existed.
*/
export default function InfoTooltip({ text }: { text: string }) {
const [open, setOpen] = useState(false)
const [position, setPosition] = useState<{ top: number; left: number } | null>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const toggle = () => {
if (!open && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect()
const left = Math.min(Math.max(rect.left, VIEWPORT_MARGIN), window.innerWidth - POPOVER_WIDTH - VIEWPORT_MARGIN)
setPosition({ top: rect.bottom + 4, left })
}
setOpen((v) => !v)
}
useEffect(() => {
if (!open) return
const onOutsideClick = (e: MouseEvent) => {
const target = e.target as Node
if (buttonRef.current?.contains(target) || popoverRef.current?.contains(target)) return
setOpen(false)
}
// Also close on scroll (e.g. the modal body it's usually opened inside)
// rather than tracking the trigger's position live — simplest way to
// avoid a stale popover that's drifted away from its "i" icon.
const close = () => setOpen(false)
document.addEventListener('mousedown', onOutsideClick)
document.addEventListener('scroll', close, true)
return () => {
document.removeEventListener('mousedown', onOutsideClick)
document.removeEventListener('scroll', close, true)
}
}, [open])
return (
<>
<button
ref={buttonRef}
type="button"
onClick={toggle}
title="More info"
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-0 bg-transparent p-0 text-slate-400 hover:text-slate-600"
>
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
<circle cx="10" cy="10" r="8.25" stroke="currentColor" strokeWidth="1.5" />
<path d="M10 9.5v5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="10" cy="6.5" r="1" fill="currentColor" />
</svg>
</button>
{open &&
position &&
createPortal(
<div
ref={popoverRef}
style={{ position: 'fixed', top: position.top, left: position.left, width: POPOVER_WIDTH }}
className="z-[60] rounded border border-slate-200 bg-white p-2 text-[11px] font-normal normal-case leading-snug text-slate-600 shadow-lg"
>
{text}
</div>,
document.body,
)}
</>
)
}
@@ -0,0 +1,100 @@
import { useMemo, useState } from 'react'
import { allManufacturers } from '../../domain/diagram'
import { useCatalogStore } from '../../state/catalogStore'
const NEW_MANUFACTURER_VALUE = '__new__'
const NONE_VALUE = ''
/** A manufacturer <select> mirroring CategorySelect's "+ New…" inline-create
* pattern, but optional — unlike a device category, not every device has a
* known manufacturer (DIY/generic gear), so this always offers a blank
* "(none)" option alongside the catalog and the create option. */
export default function ManufacturerSelect({
value,
onChange,
className = 'mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm',
publishPublic = false,
}: {
value: string | undefined
onChange: (manufacturerId: string | undefined) => void
className?: string
/** See CategorySelect's own doc — same cascade for the same reason. */
publishPublic?: boolean
}) {
const catalog = useCatalogStore((s) => s.catalog)
const addCustomManufacturer = useCatalogStore((s) => s.addCustomManufacturer)
const adminAddManufacturer = useCatalogStore((s) => s.adminAddManufacturer)
const [creating, setCreating] = useState(false)
const [draftName, setDraftName] = useState('')
const manufacturers = useMemo(
() => [...allManufacturers(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
[catalog],
)
const commitNewManufacturer = async () => {
const trimmed = draftName.trim()
if (trimmed) {
onChange(await (publishPublic ? adminAddManufacturer(trimmed) : addCustomManufacturer(trimmed)))
}
setCreating(false)
setDraftName('')
}
if (creating) {
return (
<div className="mt-1 flex gap-1.5">
<input
autoFocus
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
commitNewManufacturer()
} else if (e.key === 'Escape') {
setCreating(false)
setDraftName('')
}
}}
placeholder="New manufacturer name"
className="w-full min-w-0 flex-1 rounded border border-slate-300 px-2 py-1 text-sm"
/>
<button
onClick={commitNewManufacturer}
className="shrink-0 rounded bg-indigo-600 px-2 text-xs font-medium text-white hover:bg-indigo-500"
>
Add
</button>
<button
onClick={() => {
setCreating(false)
setDraftName('')
}}
className="shrink-0 rounded px-2 text-xs text-slate-500 hover:bg-slate-100"
>
Cancel
</button>
</div>
)
}
return (
<select
value={value ?? NONE_VALUE}
onChange={(e) => {
if (e.target.value === NEW_MANUFACTURER_VALUE) setCreating(true)
else onChange(e.target.value === NONE_VALUE ? undefined : e.target.value)
}}
className={className}
>
<option value={NONE_VALUE}>(none)</option>
{manufacturers.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
<option value={NEW_MANUFACTURER_VALUE}>+ New manufacturer…</option>
</select>
)
}
+20 -5
View File
@@ -5,6 +5,7 @@ import type { PortDirection } from '../../domain/types'
import { useCatalogStore } from '../../state/catalogStore' import { useCatalogStore } from '../../state/catalogStore'
import { useDiagramStore } from '../../state/diagramStore' import { useDiagramStore } from '../../state/diagramStore'
import CategorySelect from '../common/CategorySelect' import CategorySelect from '../common/CategorySelect'
import ManufacturerSelect from '../common/ManufacturerSelect'
export default function DeviceInspector({ deviceId }: { deviceId: string }) { export default function DeviceInspector({ deviceId }: { deviceId: string }) {
const diagram = useDiagramStore((s) => s.diagram) const diagram = useDiagramStore((s) => s.diagram)
@@ -47,10 +48,9 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
</label> </label>
<label className="text-xs font-medium text-slate-600"> <label className="text-xs font-medium text-slate-600">
Manufacturer Manufacturer
<input <ManufacturerSelect
value={device.manufacturer ?? ''} value={device.manufacturerId}
onChange={(e) => updateDevice(device.id, { manufacturer: e.target.value })} onChange={(manufacturerId) => updateDevice(device.id, { manufacturerId })}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
/> />
</label> </label>
<label className="text-xs font-medium text-slate-600"> <label className="text-xs font-medium text-slate-600">
@@ -95,7 +95,7 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
{device.ports.map((port) => { {device.ports.map((port) => {
const inUse = connectionCountForPort(port.id) > 0 const inUse = connectionCountForPort(port.id) > 0
return ( return (
<div key={port.id} className="flex items-center gap-1.5"> <div key={port.id} className="flex flex-wrap items-center gap-1.5">
<input <input
ref={(el) => { ref={(el) => {
if (el && focusPortId === port.id) { if (el && focusPortId === port.id) {
@@ -129,6 +129,20 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
</option> </option>
))} ))}
</select> </select>
{/* Grouped so these three wrap to their own line as a unit
(aligned to the row's end) rather than each one reflowing
separately if the row ever gets tighter than it can fit. */}
<div className="ml-auto flex shrink-0 items-center gap-1">
<button
onClick={() => updatePort(device.id, port.id, { builtInCable: !port.builtInCable })}
tabIndex={-1}
title="Built-in cable"
className={`shrink-0 rounded px-1 ${
port.builtInCable ? 'bg-amber-100 opacity-100 ring-1 ring-amber-300' : 'opacity-30 hover:opacity-60'
}`}
>
🔌
</button>
<button <button
onClick={() => setFocusPortId(clonePort(device.id, port.id))} onClick={() => setFocusPortId(clonePort(device.id, port.id))}
tabIndex={-1} tabIndex={-1}
@@ -147,6 +161,7 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
✕ ✕
</button> </button>
</div> </div>
</div>
) )
})} })}
</div> </div>
+67 -16
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { allCableTypes, allPortTypes } from '../../domain/diagram' import { allCableTypes, allPortTypes, familyLabel } from '../../domain/diagram'
import type { CableType, PortType } from '../../domain/types' import type { CableType, PortType } from '../../domain/types'
import { cableTypeToRow } from '../../data/catalogRowMapping' import { cableTypeToRow } from '../../data/catalogRowMapping'
import { useAdminReviewStore } from '../../state/adminReviewStore' import { useAdminReviewStore } from '../../state/adminReviewStore'
@@ -10,23 +10,19 @@ import { resolveCableTypeFields, type CableTypeFormFields } from './cableTypeFor
const UNITS: CableType['unit'][] = ['ft', 'm'] const UNITS: CableType['unit'][] = ['ft', 'm']
/** Best-effort friendly label for a raw family string: the name of whichever
* port type owns that family, or the raw string if none matches (e.g. a
* family only ever typed into a text field, not backed by a port type). */
function familyLabel(family: string, portTypes: PortType[]): string {
return portTypes.find((pt) => pt.family === family)?.name ?? family
}
export function CableTypeForm({ export function CableTypeForm({
initial, initial,
portTypes, portTypes,
showPublishToggle = false,
onCancel, onCancel,
onSave, onSave,
}: { }: {
initial?: CableType initial?: CableType
portTypes: PortType[] portTypes: PortType[]
/** See PortTypeForm's own doc — same "new + Admin" -only publish toggle. */
showPublishToggle?: boolean
onCancel: () => void onCancel: () => void
onSave: (fields: CableTypeFormFields) => void onSave: (fields: CableTypeFormFields, publishPublic: boolean) => void
}) { }) {
const initialEndA = initial ? portTypes.find((pt) => pt.family === initial.family) : undefined const initialEndA = initial ? portTypes.find((pt) => pt.family === initial.family) : undefined
const initialEndB = initial ? portTypes.find((pt) => pt.family === (initial.family2 ?? initial.family)) : undefined const initialEndB = initial ? portTypes.find((pt) => pt.family === (initial.family2 ?? initial.family)) : undefined
@@ -36,18 +32,22 @@ export function CableTypeForm({
const [endBPortTypeId, setEndBPortTypeId] = useState(initialEndB?.id ?? portTypes[0]?.id ?? '') const [endBPortTypeId, setEndBPortTypeId] = useState(initialEndB?.id ?? portTypes[0]?.id ?? '')
const [unit, setUnit] = useState<CableType['unit']>(initial?.unit ?? 'ft') const [unit, setUnit] = useState<CableType['unit']>(initial?.unit ?? 'ft')
const [costPerUnit, setCostPerUnit] = useState(initial?.costPerUnit?.toString() ?? '') const [costPerUnit, setCostPerUnit] = useState(initial?.costPerUnit?.toString() ?? '')
const [publishPublic, setPublishPublic] = useState(false)
const isAdapter = endAPortTypeId !== endBPortTypeId const isAdapter = endAPortTypeId !== endBPortTypeId
const handleSave = () => { const handleSave = () => {
if (!name.trim() || !endAPortTypeId || !endBPortTypeId) return if (!name.trim() || !endAPortTypeId || !endBPortTypeId) return
onSave({ onSave(
{
name: name.trim(), name: name.trim(),
endAPortTypeId, endAPortTypeId,
endBPortTypeId, endBPortTypeId,
unit, unit,
costPerUnit: costPerUnit.trim() === '' ? undefined : Number(costPerUnit), costPerUnit: costPerUnit.trim() === '' ? undefined : Number(costPerUnit),
}) },
publishPublic,
)
} }
return ( return (
@@ -112,6 +112,12 @@ export function CableTypeForm({
className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs" className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
/> />
</div> </div>
{showPublishToggle && (
<label className="flex items-center gap-1.5 text-[11px] text-slate-500">
<input type="checkbox" checked={publishPublic} onChange={(e) => setPublishPublic(e.target.checked)} />
Publish directly to the shared library (skips the review queue)
</label>
)}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button onClick={onCancel} className="text-[11px] text-slate-500"> <button onClick={onCancel} className="text-[11px] text-slate-500">
Cancel Cancel
@@ -131,7 +137,10 @@ export default function CableTypesPanel() {
const catalog = useCatalogStore((s) => s.catalog) const catalog = useCatalogStore((s) => s.catalog)
const addCustomCableType = useCatalogStore((s) => s.addCustomCableType) const addCustomCableType = useCatalogStore((s) => s.addCustomCableType)
const updateCustomCableType = useCatalogStore((s) => s.updateCustomCableType) const updateCustomCableType = useCatalogStore((s) => s.updateCustomCableType)
const removeCustomCableType = useCatalogStore((s) => s.removeCustomCableType)
const adminUpdateCableType = useCatalogStore((s) => s.adminUpdateCableType) const adminUpdateCableType = useCatalogStore((s) => s.adminUpdateCableType)
const adminAddCableType = useCatalogStore((s) => s.adminAddCableType)
const adminRemoveCableType = useCatalogStore((s) => s.adminRemoveCableType)
const adminUnpublish = useCatalogStore((s) => s.adminUnpublish) const adminUnpublish = useCatalogStore((s) => s.adminUnpublish)
const mySubmissions = useSubmissionStore((s) => s.mySubmissions) const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
const submitForReview = useSubmissionStore((s) => s.submitForReview) const submitForReview = useSubmissionStore((s) => s.submitForReview)
@@ -163,15 +172,16 @@ export default function CableTypesPanel() {
) )
const describeEnds = (ct: CableType) => { const describeEnds = (ct: CableType) => {
const a = familyLabel(ct.family, portTypes) const a = familyLabel(portTypes, ct.family)
const b = familyLabel(ct.family2 ?? ct.family, portTypes) const b = familyLabel(portTypes, ct.family2 ?? ct.family)
return a === b ? a : `${a} ↔ ${b}` return a === b ? a : `${a} ↔ ${b}`
} }
const handleCreate = (fields: CableTypeFormFields) => { const handleCreate = (fields: CableTypeFormFields, publishPublic: boolean) => {
const resolved = resolveCableTypeFields(fields, portTypes) const resolved = resolveCableTypeFields(fields, portTypes)
if (!resolved) return if (!resolved) return
addCustomCableType(resolved) if (isAdmin && publishPublic) adminAddCableType(resolved)
else addCustomCableType(resolved)
setCreating(false) setCreating(false)
} }
@@ -211,6 +221,29 @@ export default function CableTypesPanel() {
adminUnpublish('cable_type', ct.id) adminUnpublish('cable_type', ct.id)
} }
// Shared by Custom (your own) and Built-in (Admin permanent-delete) —
// cable types aren't referenced by any foreign key (matched by family at
// diagram-render time, not by id), so the only usage check needed is the
// same diagram-usage-impact one Unpublish already uses. Blocks outright
// rather than offering "confirm anyway" — unlike unpublish, this can't be
// undone.
const deleteIfUnused = async (ct: CableType, remove: (id: string) => Promise<void>) => {
const impact = await getUsageImpact('cable_type', ct.id)
if (impact.diagramCount > 0) {
window.alert(
`Can't delete "${ct.name}" — used in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}.`,
)
return
}
const confirmed = window.confirm(`Delete "${ct.name}"? This can't be undone.`)
if (!confirmed) return
try {
await remove(ct.id)
} catch {
window.alert(`Couldn't delete "${ct.name}" — it may still be in use somewhere.`)
}
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
@@ -226,7 +259,12 @@ export default function CableTypesPanel() {
</div> </div>
{creating && ( {creating && (
<div className="mb-1.5"> <div className="mb-1.5">
<CableTypeForm portTypes={portTypes} onCancel={() => setCreating(false)} onSave={handleCreate} /> <CableTypeForm
portTypes={portTypes}
showPublishToggle={isAdmin}
onCancel={() => setCreating(false)}
onSave={handleCreate}
/>
</div> </div>
)} )}
{customTypes.length === 0 && !creating ? ( {customTypes.length === 0 && !creating ? (
@@ -276,6 +314,12 @@ export default function CableTypesPanel() {
> >
Edit Edit
</button> </button>
<button
onClick={() => deleteIfUnused(ct, removeCustomCableType)}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600"
>
Delete
</button>
</div> </div>
</div> </div>
), ),
@@ -333,6 +377,13 @@ export default function CableTypesPanel() {
> >
Unpublish Unpublish
</button> </button>
<button
onClick={() => deleteIfUnused(ct, adminRemoveCableType)}
title="Permanently delete — only possible while nothing uses it"
className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-red-50 hover:text-red-600"
>
Delete
</button>
</div> </div>
) : pendingEntityIds.has(ct.id) ? ( ) : pendingEntityIds.has(ct.id) ? (
<span className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600"> <span className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600">
@@ -0,0 +1,44 @@
import { useState } from 'react'
import CategoryManufacturerPanel from './CategoryManufacturerPanel'
import Modal from '../common/Modal'
type Tab = 'categories' | 'manufacturers'
export default function CategoryLibraryModal({ onClose }: { onClose: () => void }) {
const [tab, setTab] = useState<Tab>('categories')
return (
<Modal title="Categories & Manufacturers" onClose={onClose} width="max-w-xl">
<div className="mb-3 flex border-b border-slate-200">
<button
onClick={() => setTab('categories')}
className={`px-3 py-1.5 text-xs font-semibold uppercase tracking-wide ${
tab === 'categories' ? 'border-b-2 border-indigo-600 text-indigo-700' : 'text-slate-400 hover:text-slate-600'
}`}
>
Device Categories
</button>
<button
onClick={() => setTab('manufacturers')}
className={`px-3 py-1.5 text-xs font-semibold uppercase tracking-wide ${
tab === 'manufacturers' ? 'border-b-2 border-indigo-600 text-indigo-700' : 'text-slate-400 hover:text-slate-600'
}`}
>
Manufacturers
</button>
</div>
{tab === 'categories' ? (
<CategoryManufacturerPanel entityType="device_category" />
) : (
<CategoryManufacturerPanel entityType="manufacturer" />
)}
<div className="mt-4 flex justify-end border-t border-slate-100 pt-3">
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
Close
</button>
</div>
</Modal>
)
}
@@ -0,0 +1,374 @@
import { useMemo, useState } from 'react'
import { deviceCategoryToRow, manufacturerToRow } from '../../data/catalogRowMapping'
import type { CatalogEntityType } from '../../data/SubmissionRepository'
import { allDeviceCategories, allManufacturers } from '../../domain/diagram'
import { useAdminReviewStore } from '../../state/adminReviewStore'
import { useAuthStore } from '../../state/authStore'
import { useCatalogStore } from '../../state/catalogStore'
import { useSubmissionStore } from '../../state/submissionStore'
interface NamedEntity {
id: string
name: string
custom?: boolean
}
/** Inline rename/create row — every action here (add, rename, suggest an
* edit) is just "type a name, confirm", so one small form covers all of
* them rather than a bespoke component per action the way port/cable
* types need (PortTypeForm/CableTypeForm) for their much richer shapes. */
function NameForm({
initialName = '',
confirmLabel,
onCancel,
onSave,
}: {
initialName?: string
confirmLabel: string
onCancel: () => void
onSave: (name: string) => void
}) {
const [name, setName] = useState(initialName)
const commit = () => {
if (name.trim()) onSave(name.trim())
}
return (
<div className="flex items-center gap-1.5 rounded border border-indigo-200 bg-indigo-50/40 p-2">
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
commit()
} else if (e.key === 'Escape') {
onCancel()
}
}}
className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<button onClick={onCancel} className="shrink-0 text-[11px] text-slate-500">
Cancel
</button>
<button
onClick={commit}
disabled={!name.trim()}
className="shrink-0 rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
>
{confirmLabel}
</button>
</div>
)
}
type EditorTarget =
| { mode: 'new' }
| { mode: 'rename' | 'suggestEdit' | 'adminEdit'; entity: NamedEntity }
/**
* Device categories and manufacturers share an identical shape (just a
* name, plus the usual is_public/owner_id split) and an identical set of
* actions — one generic panel, parameterized by entity type, rather than
* duplicating PortTypesPanel/CableTypesPanel's much larger structure twice
* for something this simple.
*/
export default function CategoryManufacturerPanel({
entityType,
}: {
entityType: Extract<CatalogEntityType, 'device_category' | 'manufacturer'>
}) {
const catalog = useCatalogStore((s) => s.catalog)
const addCustomDeviceCategory = useCatalogStore((s) => s.addCustomDeviceCategory)
const updateCustomDeviceCategory = useCatalogStore((s) => s.updateCustomDeviceCategory)
const removeCustomDeviceCategory = useCatalogStore((s) => s.removeCustomDeviceCategory)
const adminUpdateDeviceCategory = useCatalogStore((s) => s.adminUpdateDeviceCategory)
const adminAddDeviceCategory = useCatalogStore((s) => s.adminAddDeviceCategory)
const adminRemoveDeviceCategory = useCatalogStore((s) => s.adminRemoveDeviceCategory)
const addCustomManufacturer = useCatalogStore((s) => s.addCustomManufacturer)
const updateCustomManufacturer = useCatalogStore((s) => s.updateCustomManufacturer)
const removeCustomManufacturer = useCatalogStore((s) => s.removeCustomManufacturer)
const adminUpdateManufacturer = useCatalogStore((s) => s.adminUpdateManufacturer)
const adminAddManufacturer = useCatalogStore((s) => s.adminAddManufacturer)
const adminRemoveManufacturer = useCatalogStore((s) => s.adminRemoveManufacturer)
const adminUnpublish = useCatalogStore((s) => s.adminUnpublish)
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
const submitForReview = useSubmissionStore((s) => s.submitForReview)
const getUsageImpact = useAdminReviewStore((s) => s.getUsageImpact)
const role = useAuthStore((s) => s.role)
const isAdmin = role === 'admin' || role === 'super_admin'
const [editorTarget, setEditorTarget] = useState<EditorTarget | null>(null)
const [publishNewPublic, setPublishNewPublic] = useState(false)
const isCategory = entityType === 'device_category'
const label = isCategory ? 'category' : 'manufacturer'
const items: NamedEntity[] = isCategory ? allDeviceCategories(catalog) : allManufacturers(catalog)
const addCustom = isCategory ? addCustomDeviceCategory : addCustomManufacturer
const updateCustom = isCategory ? updateCustomDeviceCategory : updateCustomManufacturer
const removeCustom = isCategory ? removeCustomDeviceCategory : removeCustomManufacturer
const adminUpdate = isCategory ? adminUpdateDeviceCategory : adminUpdateManufacturer
const adminAdd = isCategory ? adminAddDeviceCategory : adminAddManufacturer
const adminRemove = isCategory ? adminRemoveDeviceCategory : adminRemoveManufacturer
const toRow = isCategory ? deviceCategoryToRow : manufacturerToRow
const sortedItems = useMemo(() => [...items].sort((a, b) => a.name.localeCompare(b.name)), [items])
const customItems = useMemo(() => sortedItems.filter((i) => i.custom), [sortedItems])
const builtInItems = useMemo(() => sortedItems.filter((i) => !i.custom), [sortedItems])
const pendingEntityIds = useMemo(
() => new Set(mySubmissions.filter((s) => s.entityType === entityType && s.status === 'pending').map((s) => s.entityId)),
[mySubmissions, entityType],
)
const handleUnpublish = async (item: NamedEntity) => {
const impact = await getUsageImpact(entityType, item.id)
const impactNote =
impact.diagramCount > 0
? `\n\nUsed in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}. Existing diagrams keep working — this only removes it from new use.`
: '\n\nNot currently used in any diagram.'
const confirmed = window.confirm(`Unpublish "${item.name}" from the public catalog?${impactNote}`)
if (!confirmed) return
adminUnpublish(entityType, item.id)
}
// Deleting your own private category/manufacturer needs two checks
// first: a device_templates.category_id/manufacturer_id foreign key
// would otherwise reject the delete outright if any device template
// still references it (checked client-side here for a friendly message
// instead of a raw DB error) — and unlike that FK, nothing stops deleting
// one still referenced by a *placed device* inside a diagram (diagrams
// are a JSONB blob, not relationally linked), so that's checked via the
// same usage-impact function Admin unpublish uses, now extended to let
// you check your own still-private entries too. Either kind of usage
// blocks the delete outright — unlike unpublish, a hard delete can't be
// undone, so "confirm anyway" isn't offered here.
const handleDelete = async (item: NamedEntity) => {
const usedByTemplate = catalog.deviceTemplates.some((t) => (isCategory ? t.category : t.manufacturerId) === item.id)
if (usedByTemplate) {
window.alert(`Can't delete "${item.name}" — it's still used by one of your devices in the palette. Change that device's ${label} first.`)
return
}
const impact = await getUsageImpact(entityType, item.id)
if (impact.diagramCount > 0) {
window.alert(
`Can't delete "${item.name}" — used in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}.`,
)
return
}
const confirmed = window.confirm(`Delete "${item.name}"? This can't be undone.`)
if (!confirmed) return
try {
await removeCustom(item.id)
} catch {
window.alert(`Couldn't delete "${item.name}" — it may still be in use somewhere.`)
}
}
// Admin permanent-delete of a built-in entry — same zero-usage gate as
// handleDelete above (nothing stops here that unpublish wouldn't also
// have blocked with a confirm; this just doesn't offer "confirm anyway"
// since a hard delete can't be undone the way unpublish can).
const handleAdminDelete = async (item: NamedEntity) => {
const usedByTemplate = catalog.deviceTemplates.some((t) => (isCategory ? t.category : t.manufacturerId) === item.id)
if (usedByTemplate) {
window.alert(`Can't delete "${item.name}" — it's still used by a device template. Change that device's ${label} first.`)
return
}
const impact = await getUsageImpact(entityType, item.id)
if (impact.diagramCount > 0) {
window.alert(
`Can't delete "${item.name}" — used in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}.`,
)
return
}
const confirmed = window.confirm(`Permanently delete "${item.name}" from the shared library? This can't be undone.`)
if (!confirmed) return
try {
await adminRemove(item.id)
} catch {
window.alert(`Couldn't delete "${item.name}" — it may still be referenced somewhere.`)
}
}
return (
<div className="space-y-4">
<div>
<div className="mb-1 flex items-center justify-between">
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Custom ({customItems.length})
</h4>
{editorTarget?.mode !== 'new' && (
<button
onClick={() => setEditorTarget({ mode: 'new' })}
className="text-[11px] font-medium text-indigo-600 hover:underline"
>
+ New {label}
</button>
)}
</div>
{editorTarget?.mode === 'new' && (
<div className="mb-1.5 space-y-1.5">
{isAdmin && (
<label className="flex items-center gap-1.5 text-[11px] text-slate-500">
<input
type="checkbox"
checked={publishNewPublic}
onChange={(e) => setPublishNewPublic(e.target.checked)}
/>
Publish directly to the shared library (skips the review queue)
</label>
)}
<NameForm
confirmLabel="Add"
onCancel={() => setEditorTarget(null)}
onSave={(name) => {
if (isAdmin && publishNewPublic) adminAdd(name)
else addCustom(name)
setEditorTarget(null)
setPublishNewPublic(false)
}}
/>
</div>
)}
{customItems.length === 0 && editorTarget?.mode !== 'new' ? (
<p className="text-[11px] italic text-slate-400">None yet.</p>
) : (
<div className="space-y-1.5">
{customItems.map((item) =>
editorTarget?.mode === 'rename' && editorTarget.entity.id === item.id ? (
<NameForm
key={item.id}
initialName={item.name}
confirmLabel="Save"
onCancel={() => setEditorTarget(null)}
onSave={(name) => {
updateCustom(item.id, name)
setEditorTarget(null)
}}
/>
) : (
<div
key={item.id}
className="flex items-center justify-between gap-2 rounded border border-slate-200 bg-white px-2 py-1.5 text-xs"
>
<span className="min-w-0 truncate font-medium text-slate-700">{item.name}</span>
<div className="flex shrink-0 items-center gap-1">
{pendingEntityIds.has(item.id) ? (
<span className="rounded bg-amber-50 px-2 py-1 text-[11px] font-medium text-amber-600">
Pending review
</span>
) : (
<button
onClick={() => submitForReview(entityType, item.id, toRow(item.name))}
title={`Submit this ${label} to the public catalog for Admin review`}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-emerald-100 hover:text-emerald-700"
>
Submit
</button>
)}
<button
onClick={() => setEditorTarget({ mode: 'rename', entity: item })}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
>
Rename
</button>
<button
onClick={() => handleDelete(item)}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600"
>
Delete
</button>
</div>
</div>
),
)}
</div>
)}
</div>
<div>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
Built-in ({builtInItems.length})
</h4>
<div className="max-h-64 space-y-1 overflow-y-auto rounded border border-slate-200 p-1.5">
{builtInItems.map((item) => {
if (isAdmin && editorTarget?.mode === 'adminEdit' && editorTarget.entity.id === item.id) {
return (
<div key={item.id} className="mb-1.5">
<NameForm
initialName={item.name}
confirmLabel="Save"
onCancel={() => setEditorTarget(null)}
onSave={(name) => {
adminUpdate(item.id, name)
setEditorTarget(null)
}}
/>
</div>
)
}
if (!isAdmin && editorTarget?.mode === 'suggestEdit' && editorTarget.entity.id === item.id) {
return (
<div key={item.id} className="mb-1.5">
<NameForm
initialName={item.name}
confirmLabel="Submit"
onCancel={() => setEditorTarget(null)}
onSave={(name) => {
submitForReview(entityType, item.id, toRow(name))
setEditorTarget(null)
}}
/>
</div>
)
}
return (
<div key={item.id} className="flex items-center justify-between gap-2 px-1 py-0.5 text-[11px] text-slate-500">
<span className="min-w-0 truncate">{item.name}</span>
{isAdmin ? (
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => setEditorTarget({ mode: 'adminEdit', entity: item })}
className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
Edit
</button>
<button
onClick={() => handleUnpublish(item)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-red-50 hover:text-red-600"
>
Unpublish
</button>
<button
onClick={() => handleAdminDelete(item)}
title="Permanently delete — only possible while nothing uses it"
className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-red-50 hover:text-red-600"
>
Delete
</button>
</div>
) : pendingEntityIds.has(item.id) ? (
<span className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600">
Edit pending review
</span>
) : (
<button
onClick={() => setEditorTarget({ mode: 'suggestEdit', entity: item })}
className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
Suggest edit
</button>
)}
</div>
)
})}
</div>
<p className="mt-1 text-[10px] text-slate-400">
{isAdmin
? `As an Admin, you can edit or unpublish public ${label}s directly.`
: `Built-in ${label}s can't be edited directly — "Suggest edit" sends a proposed change to Admins for review.`}
</p>
</div>
</div>
)
}
+343 -124
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { deviceTemplateToRow } from '../../data/catalogRowMapping' import { deviceTemplateToRow } from '../../data/catalogRowMapping'
import { allDeviceCategories, allDeviceTemplates, hiddenDeviceTemplates } from '../../domain/diagram' import { allDeviceTemplates, deviceCategoryName, hiddenDeviceTemplates, manufacturerName } from '../../domain/diagram'
import type { DeviceTemplate } from '../../domain/types' import type { DeviceTemplate } from '../../domain/types'
import { useAdminReviewStore } from '../../state/adminReviewStore' import { useAdminReviewStore } from '../../state/adminReviewStore'
import { useAuthStore } from '../../state/authStore' import { useAuthStore } from '../../state/authStore'
@@ -9,6 +9,8 @@ import { useDiagramStore } from '../../state/diagramStore'
import { useSubmissionStore } from '../../state/submissionStore' import { useSubmissionStore } from '../../state/submissionStore'
import { TEMPLATE_DRAG_MIME } from '../canvas/FlowCanvas' import { TEMPLATE_DRAG_MIME } from '../canvas/FlowCanvas'
import Chevron from '../common/Chevron' import Chevron from '../common/Chevron'
import DropdownMenu, { DropdownMenuItem } from '../common/DropdownMenu'
import CategoryLibraryModal from './CategoryLibraryModal'
import ConnectorLibraryModal from './ConnectorLibraryModal' import ConnectorLibraryModal from './ConnectorLibraryModal'
import DeviceTemplateEditor from './DeviceTemplateEditor' import DeviceTemplateEditor from './DeviceTemplateEditor'
@@ -18,6 +20,39 @@ type EditorTarget =
| { mode: 'suggestEdit'; template: DeviceTemplate } | { mode: 'suggestEdit'; template: DeviceTemplate }
| { mode: 'adminEdit'; template: DeviceTemplate } | { mode: 'adminEdit'; template: DeviceTemplate }
/** organized-ideas.md §5: browsable both category→manufacturer and
* manufacturer→category, not a single fixed hierarchy — the two view
* modes swap which dimension is the top-level (collapsible) group and
* which is a plain sub-heading within it. */
type ViewMode = 'category' | 'manufacturer'
/** Sentinel sub/top-group key for devices with no manufacturer set —
* distinct from any real id (manufacturer ids are uuids, this isn't), and
* always sorted last within its level since it's a fallback bucket, not a
* named group someone would look for alphabetically. */
const NO_MANUFACTURER_KEY = '__no-manufacturer__'
interface SubGroup {
key: string
label: string
templates: DeviceTemplate[]
}
interface TopGroup {
key: string
label: string
subGroups: SubGroup[]
count: number
}
function sortGroupsByLabel<T extends { key: string; label: string }>(groups: T[]): T[] {
return groups.sort((a, b) => {
if (a.key === NO_MANUFACTURER_KEY) return 1
if (b.key === NO_MANUFACTURER_KEY) return -1
return a.label.localeCompare(b.label)
})
}
export default function DevicePalette() { export default function DevicePalette() {
const diagram = useDiagramStore((s) => s.diagram) const diagram = useDiagramStore((s) => s.diagram)
const access = useDiagramStore((s) => s.access) const access = useDiagramStore((s) => s.access)
@@ -29,6 +64,7 @@ export default function DevicePalette() {
const hidePublicDeviceTemplate = useCatalogStore((s) => s.hidePublicDeviceTemplate) const hidePublicDeviceTemplate = useCatalogStore((s) => s.hidePublicDeviceTemplate)
const restorePublicDeviceTemplate = useCatalogStore((s) => s.restorePublicDeviceTemplate) const restorePublicDeviceTemplate = useCatalogStore((s) => s.restorePublicDeviceTemplate)
const adminUnpublish = useCatalogStore((s) => s.adminUnpublish) const adminUnpublish = useCatalogStore((s) => s.adminUnpublish)
const adminRemoveDeviceTemplate = useCatalogStore((s) => s.adminRemoveDeviceTemplate)
const mySubmissions = useSubmissionStore((s) => s.mySubmissions) const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
const submitForReview = useSubmissionStore((s) => s.submitForReview) const submitForReview = useSubmissionStore((s) => s.submitForReview)
const getUsageImpact = useAdminReviewStore((s) => s.getUsageImpact) const getUsageImpact = useAdminReviewStore((s) => s.getUsageImpact)
@@ -36,40 +72,77 @@ export default function DevicePalette() {
const isAdmin = role === 'admin' || role === 'super_admin' const isAdmin = role === 'admin' || role === 'super_admin'
const [editorTarget, setEditorTarget] = useState<EditorTarget | null>(null) const [editorTarget, setEditorTarget] = useState<EditorTarget | null>(null)
const [portTypesOpen, setPortTypesOpen] = useState(false) const [portTypesOpen, setPortTypesOpen] = useState(false)
const [categoryLibraryOpen, setCategoryLibraryOpen] = useState(false)
const [hiddenListOpen, setHiddenListOpen] = useState(false) const [hiddenListOpen, setHiddenListOpen] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('category')
const [search, setSearch] = useState('')
// Collapsed by default — expanding on demand is the whole point (keeps a // Collapsed by default — expanding on demand is the whole point (keeps a
// library with many devices from turning into one long scroll). // library with many devices from turning into one long scroll). Shared
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set()) // across both view modes rather than tracked per-mode: category ids and
// manufacturer ids live in different id spaces, so a stale entry left
// over from the other mode just never matches anything, harmlessly.
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
const groups = useMemo(() => { const groups = useMemo<TopGroup[]>(() => {
const categories = allDeviceCategories(catalog) const templates = allDeviceTemplates(catalog, hiddenPublicIds)
const categoryName = (id: string) => categories.find((c) => c.id === id)?.name ?? id const categoryLabel = (id: string) => deviceCategoryName(catalog, id)
const byCategory = new Map<string, DeviceTemplate[]>() const manufacturerLabel = (id: string) =>
for (const template of allDeviceTemplates(catalog, hiddenPublicIds)) { id === NO_MANUFACTURER_KEY ? 'No manufacturer' : (manufacturerName(catalog, id) ?? id)
const list = byCategory.get(template.category) ?? [] const manufacturerKey = (t: DeviceTemplate) => t.manufacturerId ?? NO_MANUFACTURER_KEY
list.push(template)
byCategory.set(template.category, list) const byTop = new Map<string, Map<string, DeviceTemplate[]>>()
for (const t of templates) {
const topKey = viewMode === 'category' ? t.category : manufacturerKey(t)
const subKey = viewMode === 'category' ? manufacturerKey(t) : t.category
const subMap = byTop.get(topKey) ?? new Map<string, DeviceTemplate[]>()
subMap.set(subKey, [...(subMap.get(subKey) ?? []), t])
byTop.set(topKey, subMap)
} }
return [...byCategory.entries()]
.map(([categoryId, templates]) => ({
categoryId,
categoryName: categoryName(categoryId),
templates: templates.sort((a, b) => a.name.localeCompare(b.name)),
}))
.sort((a, b) => a.categoryName.localeCompare(b.categoryName))
}, [catalog, hiddenPublicIds])
const toggleCategory = (categoryId: string) => { const topGroups = [...byTop.entries()].map(([topKey, subMap]) => {
setExpandedCategories((prev) => { const subGroups = sortGroupsByLabel(
[...subMap.entries()].map(([subKey, list]) => ({
key: subKey,
label: viewMode === 'category' ? manufacturerLabel(subKey) : categoryLabel(subKey),
templates: list.sort((a, b) => a.name.localeCompare(b.name)),
})),
)
return {
key: topKey,
label: viewMode === 'category' ? categoryLabel(topKey) : manufacturerLabel(topKey),
subGroups,
count: subGroups.reduce((sum, sg) => sum + sg.templates.length, 0),
}
})
return sortGroupsByLabel(topGroups)
}, [catalog, hiddenPublicIds, viewMode])
const searchResults = useMemo(() => {
const query = search.trim().toLowerCase()
if (!query) return null
return allDeviceTemplates(catalog, hiddenPublicIds)
.filter((t) => {
const haystack = [t.name, t.model, manufacturerName(catalog, t.manufacturerId), deviceCategoryName(catalog, t.category)]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(query)
})
.sort((a, b) => a.name.localeCompare(b.name))
}, [catalog, hiddenPublicIds, search])
const toggleGroup = (key: string) => {
setExpandedGroups((prev) => {
const next = new Set(prev) const next = new Set(prev)
if (next.has(categoryId)) next.delete(categoryId) if (next.has(key)) next.delete(key)
else next.add(categoryId) else next.add(key)
return next return next
}) })
} }
const expandCategory = (categoryId: string) => { const expandGroup = (key: string) => {
setExpandedCategories((prev) => new Set(prev).add(categoryId)) setExpandedGroups((prev) => new Set(prev).add(key))
} }
const handleDragStart = (event: React.DragEvent, template: DeviceTemplate) => { const handleDragStart = (event: React.DragEvent, template: DeviceTemplate) => {
@@ -124,18 +197,53 @@ export default function DevicePalette() {
adminUnpublish('device_template', template.id) adminUnpublish('device_template', template.id)
} }
// Distinct from the ✕ button below, which for a built-in device only ever
// hides it from *your own* view (a per-user preference, not a catalog
// change) — this permanently removes it from the shared library, gated by
// zero usage the same way a private device's delete already is. Device
// templates aren't referenced by any foreign key, so the diagram-usage
// check is the only thing to verify.
const handleAdminDelete = async (template: DeviceTemplate) => {
const impact = await getUsageImpact('device_template', template.id)
if (impact.diagramCount > 0) {
window.alert(
`Can't delete "${template.name}" — used in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}.`,
)
return
}
const confirmed = window.confirm(`Permanently delete "${template.name}" from the shared library? This can't be undone.`)
if (!confirmed) return
try {
await adminRemoveDeviceTemplate(template.id)
} catch {
window.alert(`Couldn't delete "${template.name}" — it may still be referenced somewhere.`)
}
}
const rowProps = {
canEdit,
isAdmin,
pendingEntityIds,
onDragStart: handleDragStart,
onQuickAdd: handleQuickAdd,
onDelete: handleDelete,
onSubmitForPublic: handleSubmitForPublic,
onUnpublish: handleUnpublish,
onAdminDelete: handleAdminDelete,
onSuggestEdit: (template: DeviceTemplate) => setEditorTarget({ mode: 'suggestEdit', template }),
onAdminEdit: (template: DeviceTemplate) => setEditorTarget({ mode: 'adminEdit', template }),
onEdit: (template: DeviceTemplate) => setEditorTarget({ mode: 'edit', template }),
}
return ( return (
<aside className="flex h-full w-64 shrink-0 flex-col border-r border-slate-200 bg-slate-50"> <aside className="flex h-full w-64 shrink-0 flex-col border-r border-slate-200 bg-slate-50">
<div className="flex items-center justify-between border-b border-slate-200 px-3 py-2"> <div className="flex items-center justify-between border-b border-slate-200 px-3 py-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Devices</h2> <h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Devices</h2>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<button <DropdownMenu label="Library">
onClick={() => setPortTypesOpen(true)} <DropdownMenuItem onClick={() => setPortTypesOpen(true)}>Connectors</DropdownMenuItem>
title="Manage connector types" <DropdownMenuItem onClick={() => setCategoryLibraryOpen(true)}>Categories &amp; Manufacturers</DropdownMenuItem>
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-slate-200" </DropdownMenu>
>
Connectors
</button>
<button <button
onClick={() => setEditorTarget({ mode: 'new' })} onClick={() => setEditorTarget({ mode: 'new' })}
className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500" className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500"
@@ -144,116 +252,86 @@ export default function DevicePalette() {
</button> </button>
</div> </div>
</div> </div>
<div className="space-y-1.5 border-b border-slate-200 px-2 py-1.5">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search devices…"
className="w-full rounded border border-slate-300 px-2 py-1 text-xs"
/>
{!search && (
<div className="flex rounded bg-slate-100 p-0.5 text-[11px] font-medium">
<button
onClick={() => setViewMode('category')}
className={`flex-1 rounded px-1.5 py-0.5 ${viewMode === 'category' ? 'bg-white text-slate-700 shadow-sm' : 'text-slate-500'}`}
>
By Category
</button>
<button
onClick={() => setViewMode('manufacturer')}
className={`flex-1 rounded px-1.5 py-0.5 ${viewMode === 'manufacturer' ? 'bg-white text-slate-700 shadow-sm' : 'text-slate-500'}`}
>
By Manufacturer
</button>
</div>
)}
</div>
{!search && (
<p className="border-b border-slate-200 px-3 py-2 text-[11px] text-slate-500"> <p className="border-b border-slate-200 px-3 py-2 text-[11px] text-slate-500">
Drag a device onto the canvas, or click + to drop it in. Drag a device onto the canvas, or click + to drop it in.
</p> </p>
)}
<div className="flex-1 overflow-y-auto p-2"> <div className="flex-1 overflow-y-auto p-2">
{groups.map((group) => { {searchResults ? (
const expanded = expandedCategories.has(group.categoryId) searchResults.length === 0 ? (
<p className="px-1.5 py-2 text-[11px] italic text-slate-400">No devices match "{search.trim()}".</p>
) : (
<ul className="space-y-1">
{searchResults.map((template) => (
<DeviceRow key={template.id} template={template} showContext {...rowProps} />
))}
</ul>
)
) : (
groups.map((group) => {
const expanded = expandedGroups.has(group.key)
return ( return (
<div key={group.categoryId} className="mb-1"> <div key={group.key} className="mb-1">
<button <button
onClick={() => toggleCategory(group.categoryId)} onClick={() => toggleGroup(group.key)}
className="flex w-full items-center justify-between rounded px-1.5 py-1 text-left text-xs font-medium text-slate-600 hover:bg-slate-100" className="flex w-full items-center justify-between rounded px-1.5 py-1 text-left text-xs font-medium text-slate-600 hover:bg-slate-100"
> >
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Chevron expanded={expanded} className="text-slate-400" /> <Chevron expanded={expanded} className="text-slate-400" />
{group.categoryName} {group.label}
</span> </span>
<span className="text-[10px] text-slate-400">{group.templates.length}</span> <span className="text-[10px] text-slate-400">{group.count}</span>
</button> </button>
{expanded && ( {expanded && (
<ul className="mt-1 space-y-1 pl-2"> <div className="mt-1 space-y-2 pl-2">
{group.templates.map((template) => ( {group.subGroups.map((subGroup) => (
<li key={template.id} className="group"> <div key={subGroup.key}>
<div {/* Not independently collapsible, unlike the top-level
draggable={canEdit} group — expanding the category/manufacturer above is
onDragStart={(e) => handleDragStart(e, template)} the one click this menu asks for; a second click per
className={`flex items-center justify-between rounded border border-slate-200 bg-white px-2 py-1.5 text-xs shadow-sm ${canEdit ? 'cursor-grab active:cursor-grabbing' : ''}`} sub-group would be one too many for groups that
> usually hold just a couple of devices. */}
<div className="min-w-0"> <div className="px-1.5 text-[10px] font-medium uppercase tracking-wide text-slate-400">
<div className="truncate font-medium text-slate-700">{template.name}</div> {subGroup.label}
<div className="truncate text-[10px] text-slate-400">
{template.ports.length} port{template.ports.length === 1 ? '' : 's'}
{template.custom ? ' · custom' : ''}
</div> </div>
</div> <ul className="mt-0.5 space-y-1">
<div className="ml-2 flex shrink-0 items-center gap-1 opacity-0 group-hover:opacity-100"> {subGroup.templates.map((template) => (
{pendingEntityIds.has(template.id) ? ( <DeviceRow key={template.id} template={template} {...rowProps} />
<span
title="A submission for this device is pending Admin review"
className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600"
>
Pending
</span>
) : template.custom ? (
<button
onClick={() => handleSubmitForPublic(template)}
title="Submit this to the public catalog for Admin review"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-emerald-100 hover:text-emerald-700"
>
⇪
</button>
) : isAdmin ? (
<>
<button
onClick={() => setEditorTarget({ mode: 'adminEdit', template })}
title="Edit this public device directly"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
✎
</button>
<button
onClick={() => handleUnpublish(template)}
title="Unpublish from the public catalog"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-red-50 hover:text-red-600"
>
⛔
</button>
</>
) : (
<button
onClick={() => setEditorTarget({ mode: 'suggestEdit', template })}
title="Suggest an edit to this device for Admin review"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
✎
</button>
)}
{template.custom && (
<button
onClick={() => setEditorTarget({ mode: 'edit', template })}
title="Edit this device"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
✎
</button>
)}
<button
onClick={() => handleDelete(template)}
title={template.custom ? 'Delete from library' : 'Hide this default device'}
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-red-50 hover:text-red-600"
>
✕
</button>
{canEdit && (
<button
onClick={() => handleQuickAdd(template)}
title="Add to canvas"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
+
</button>
)}
</div>
</div>
</li>
))} ))}
</ul> </ul>
</div>
))}
</div>
)} )}
</div> </div>
) )
})} })
)}
</div> </div>
{hiddenDefaults.length > 0 && ( {hiddenDefaults.length > 0 && (
<div className="border-t border-slate-200 px-2 py-1.5"> <div className="border-t border-slate-200 px-2 py-1.5">
@@ -289,10 +367,151 @@ export default function DevicePalette() {
submissionMode={editorTarget.mode === 'suggestEdit'} submissionMode={editorTarget.mode === 'suggestEdit'}
adminMode={editorTarget.mode === 'adminEdit'} adminMode={editorTarget.mode === 'adminEdit'}
onClose={() => setEditorTarget(null)} onClose={() => setEditorTarget(null)}
onSaved={(categoryId) => expandCategory(categoryId)} onSaved={(categoryId, manufacturerId) =>
expandGroup(viewMode === 'category' ? categoryId : (manufacturerId ?? NO_MANUFACTURER_KEY))
}
/> />
)} )}
{portTypesOpen && <ConnectorLibraryModal onClose={() => setPortTypesOpen(false)} />} {portTypesOpen && <ConnectorLibraryModal onClose={() => setPortTypesOpen(false)} />}
{categoryLibraryOpen && <CategoryLibraryModal onClose={() => setCategoryLibraryOpen(false)} />}
</aside> </aside>
) )
} }
/** One draggable device row, with whatever action buttons the current user
* and this template's public/custom/pending status allow — shared by the
* grouped tree and the flat search-results list so the two don't drift. */
function DeviceRow({
template,
canEdit,
isAdmin,
pendingEntityIds,
showContext,
onDragStart,
onQuickAdd,
onDelete,
onSubmitForPublic,
onUnpublish,
onAdminDelete,
onSuggestEdit,
onAdminEdit,
onEdit,
}: {
template: DeviceTemplate
canEdit: boolean
isAdmin: boolean
pendingEntityIds: Set<string>
/** Set in search results, where there's no grouping header to already
* convey which category/manufacturer this device belongs to. */
showContext?: boolean
onDragStart: (event: React.DragEvent, template: DeviceTemplate) => void
onQuickAdd: (template: DeviceTemplate) => void
onDelete: (template: DeviceTemplate) => void
onSubmitForPublic: (template: DeviceTemplate) => void
onUnpublish: (template: DeviceTemplate) => void
onAdminDelete: (template: DeviceTemplate) => void
onSuggestEdit: (template: DeviceTemplate) => void
onAdminEdit: (template: DeviceTemplate) => void
onEdit: (template: DeviceTemplate) => void
}) {
const catalog = useCatalogStore((s) => s.catalog)
const context = showContext
? [deviceCategoryName(catalog, template.category), manufacturerName(catalog, template.manufacturerId)]
.filter(Boolean)
.join(' · ')
: undefined
return (
<li className="group">
<div
draggable={canEdit}
onDragStart={(e) => onDragStart(e, template)}
className={`flex items-center justify-between rounded border border-slate-200 bg-white px-2 py-1.5 text-xs shadow-sm ${canEdit ? 'cursor-grab active:cursor-grabbing' : ''}`}
>
<div className="min-w-0">
<div className="truncate font-medium text-slate-700">{template.name}</div>
<div className="truncate text-[10px] text-slate-400">
{context && <>{context} · </>}
{template.ports.length} port{template.ports.length === 1 ? '' : 's'}
{template.custom ? ' · custom' : ''}
</div>
</div>
<div className="ml-2 flex shrink-0 items-center gap-1 opacity-0 group-hover:opacity-100">
{pendingEntityIds.has(template.id) ? (
<span
title="A submission for this device is pending Admin review"
className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600"
>
Pending
</span>
) : template.custom ? (
<button
onClick={() => onSubmitForPublic(template)}
title="Submit this to the public catalog for Admin review"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-emerald-100 hover:text-emerald-700"
>
⇪
</button>
) : isAdmin ? (
<>
<button
onClick={() => onAdminEdit(template)}
title="Edit this public device directly"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
✎
</button>
<button
onClick={() => onUnpublish(template)}
title="Unpublish from the public catalog"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-red-50 hover:text-red-600"
>
⛔
</button>
<button
onClick={() => onAdminDelete(template)}
title="Permanently delete from the shared library — only possible while nothing uses it"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-red-50 hover:text-red-600"
>
🗑
</button>
</>
) : (
<button
onClick={() => onSuggestEdit(template)}
title="Suggest an edit to this device for Admin review"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
✎
</button>
)}
{template.custom && (
<button
onClick={() => onEdit(template)}
title="Edit this device"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
✎
</button>
)}
<button
onClick={() => onDelete(template)}
title={template.custom ? 'Delete from library' : 'Hide this default device'}
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-red-50 hover:text-red-600"
>
✕
</button>
{canEdit && (
<button
onClick={() => onQuickAdd(template)}
title="Add to canvas"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
+
</button>
)}
</div>
</div>
</li>
)
}
+87 -26
View File
@@ -3,9 +3,12 @@ import { deviceTemplateToRow } from '../../data/catalogRowMapping'
import { PORT_CATEGORIES, PORT_DIRECTIONS } from '../../domain/constants' import { PORT_CATEGORIES, PORT_DIRECTIONS } from '../../domain/constants'
import { allPortTypes, expandCompatibleFamilyIds, incrementPortName } from '../../domain/diagram' import { allPortTypes, expandCompatibleFamilyIds, incrementPortName } from '../../domain/diagram'
import type { DeviceTemplate, PortDirection } from '../../domain/types' import type { DeviceTemplate, PortDirection } from '../../domain/types'
import { useAuthStore } from '../../state/authStore'
import { useCatalogStore } from '../../state/catalogStore' import { useCatalogStore } from '../../state/catalogStore'
import { useSubmissionStore } from '../../state/submissionStore' import { useSubmissionStore } from '../../state/submissionStore'
import CategorySelect from '../common/CategorySelect' import CategorySelect from '../common/CategorySelect'
import FamilySelect from '../common/FamilySelect'
import ManufacturerSelect from '../common/ManufacturerSelect'
import Modal from '../common/Modal' import Modal from '../common/Modal'
interface DraftPort { interface DraftPort {
@@ -13,6 +16,7 @@ interface DraftPort {
name: string name: string
direction: PortDirection direction: PortDirection
portTypeId: string portTypeId: string
builtInCable?: boolean
} }
let draftKeySeq = 0 let draftKeySeq = 0
@@ -45,17 +49,27 @@ export default function DeviceTemplateEditor({
* `submissionMode`/`adminMode` if somehow more than one is set. */ * `submissionMode`/`adminMode` if somehow more than one is set. */
resubmitId?: string resubmitId?: string
onClose: () => void onClose: () => void
/** Called with the template's category id right before closing, so the /** Called with the template's category and manufacturer ids right before
* palette can expand that category and reveal what was just created/edited. */ * closing, so the palette can expand whichever group (category or
onSaved?: (categoryId: string) => void * manufacturer, depending on its current view mode) contains what was
* just created/edited. */
onSaved?: (categoryId: string, manufacturerId: string | undefined) => void
}) { }) {
const catalog = useCatalogStore((s) => s.catalog) const catalog = useCatalogStore((s) => s.catalog)
const addCustomDeviceTemplate = useCatalogStore((s) => s.addCustomDeviceTemplate) const addCustomDeviceTemplate = useCatalogStore((s) => s.addCustomDeviceTemplate)
const updateCustomDeviceTemplate = useCatalogStore((s) => s.updateCustomDeviceTemplate) const updateCustomDeviceTemplate = useCatalogStore((s) => s.updateCustomDeviceTemplate)
const adminUpdateDeviceTemplate = useCatalogStore((s) => s.adminUpdateDeviceTemplate) const adminUpdateDeviceTemplate = useCatalogStore((s) => s.adminUpdateDeviceTemplate)
const adminAddDeviceTemplate = useCatalogStore((s) => s.adminAddDeviceTemplate)
const addCustomPortType = useCatalogStore((s) => s.addCustomPortType) const addCustomPortType = useCatalogStore((s) => s.addCustomPortType)
const adminAddPortType = useCatalogStore((s) => s.adminAddPortType)
const submitForReview = useSubmissionStore((s) => s.submitForReview) const submitForReview = useSubmissionStore((s) => s.submitForReview)
const resubmit = useSubmissionStore((s) => s.resubmit) const resubmit = useSubmissionStore((s) => s.resubmit)
const role = useAuthStore((s) => s.role)
const isAdmin = role === 'admin' || role === 'super_admin'
// Only meaningful when creating a genuinely new device (not editing,
// suggesting, resubmitting, or admin-editing an existing public one) —
// those all already have their own place in the ownership/review model.
const canPublishDirectly = isAdmin && !template && !submissionMode && !adminMode && !resubmitId
const portTypes = useMemo( const portTypes = useMemo(
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)), () => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
@@ -64,12 +78,18 @@ export default function DeviceTemplateEditor({
const [name, setName] = useState(template?.name ?? '') const [name, setName] = useState(template?.name ?? '')
const [category, setCategory] = useState<string>(template?.category ?? 'other') const [category, setCategory] = useState<string>(template?.category ?? 'other')
const [manufacturer, setManufacturer] = useState(template?.manufacturer ?? '') const [manufacturerId, setManufacturerId] = useState<string | undefined>(template?.manufacturerId)
const [model, setModel] = useState(template?.model ?? '') const [model, setModel] = useState(template?.model ?? '')
const [cost, setCost] = useState(template?.cost?.toString() ?? '') const [cost, setCost] = useState(template?.cost?.toString() ?? '')
const [ports, setPorts] = useState<DraftPort[]>( const [ports, setPorts] = useState<DraftPort[]>(
() => () =>
template?.ports.map((p) => ({ key: p.id, name: p.name, direction: p.direction, portTypeId: p.portTypeId })) ?? [ template?.ports.map((p) => ({
key: p.id,
name: p.name,
direction: p.direction,
portTypeId: p.portTypeId,
builtInCable: p.builtInCable,
})) ?? [
{ key: nextDraftKey(), name: 'Port 1', direction: 'output', portTypeId: portTypes[0]?.id ?? '' }, { key: nextDraftKey(), name: 'Port 1', direction: 'output', portTypeId: portTypes[0]?.id ?? '' },
], ],
) )
@@ -79,6 +99,7 @@ export default function DeviceTemplateEditor({
const [newPortTypeCategory, setNewPortTypeCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>('other') const [newPortTypeCategory, setNewPortTypeCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>('other')
const [alsoCompatibleWith, setAlsoCompatibleWith] = useState<Set<string>>(new Set()) const [alsoCompatibleWith, setAlsoCompatibleWith] = useState<Set<string>>(new Set())
const [focusKey, setFocusKey] = useState<string | null>(null) const [focusKey, setFocusKey] = useState<string | null>(null)
const [publishDirectly, setPublishDirectly] = useState(false)
const toggleAlsoCompatible = (portTypeId: string) => { const toggleAlsoCompatible = (portTypeId: string) => {
setAlsoCompatibleWith((prev) => { setAlsoCompatibleWith((prev) => {
@@ -121,12 +142,17 @@ export default function DeviceTemplateEditor({
const handleCreatePortType = () => { const handleCreatePortType = () => {
if (!newPortTypeName.trim()) return if (!newPortTypeName.trim()) return
const compatibleFamilyIds = expandCompatibleFamilyIds([...alsoCompatibleWith], portTypes) const compatibleFamilyIds = expandCompatibleFamilyIds([...alsoCompatibleWith], portTypes)
addCustomPortType({ const fields = {
name: newPortTypeName.trim(), name: newPortTypeName.trim(),
category: newPortTypeCategory, category: newPortTypeCategory,
family: (newPortTypeFamily.trim() || newPortTypeName.trim()).toLowerCase().replace(/\s+/g, '-'), family: (newPortTypeFamily.trim() || newPortTypeName.trim()).toLowerCase().replace(/\s+/g, '-'),
...(compatibleFamilyIds.length > 0 ? { compatibleFamilyIds } : {}), ...(compatibleFamilyIds.length > 0 ? { compatibleFamilyIds } : {}),
}) }
// If this device is going straight into the public catalog, a connector
// type invented for it needs to as well — otherwise the public device
// ends up referencing a private port type nobody else can see.
if (canPublishDirectly && publishDirectly) adminAddPortType(fields)
else addCustomPortType(fields)
setNewPortTypeName('') setNewPortTypeName('')
setNewPortTypeFamily('') setNewPortTypeFamily('')
setAlsoCompatibleWith(new Set()) setAlsoCompatibleWith(new Set())
@@ -140,10 +166,16 @@ export default function DeviceTemplateEditor({
const fields = { const fields = {
name: name.trim(), name: name.trim(),
category, category,
manufacturer: manufacturer.trim() || undefined, manufacturerId,
model: model.trim() || undefined, model: model.trim() || undefined,
cost: cost.trim() === '' ? undefined : Number(cost), cost: cost.trim() === '' ? undefined : Number(cost),
ports: ports.map((p) => ({ id: p.key, name: p.name.trim() || 'Port', direction: p.direction, portTypeId: p.portTypeId })), ports: ports.map((p) => ({
id: p.key,
name: p.name.trim() || 'Port',
direction: p.direction,
portTypeId: p.portTypeId,
builtInCable: p.builtInCable,
})),
} }
if (resubmitId) { if (resubmitId) {
resubmit(resubmitId, deviceTemplateToRow(fields)) resubmit(resubmitId, deviceTemplateToRow(fields))
@@ -153,10 +185,12 @@ export default function DeviceTemplateEditor({
submitForReview('device_template', template.id, deviceTemplateToRow(fields)) submitForReview('device_template', template.id, deviceTemplateToRow(fields))
} else if (template) { } else if (template) {
updateCustomDeviceTemplate(template.id, fields) updateCustomDeviceTemplate(template.id, fields)
} else if (canPublishDirectly && publishDirectly) {
adminAddDeviceTemplate(fields)
} else { } else {
addCustomDeviceTemplate(fields) addCustomDeviceTemplate(fields)
} }
onSaved?.(category) onSaved?.(category, manufacturerId)
onClose() onClose()
} }
@@ -189,14 +223,14 @@ export default function DeviceTemplateEditor({
</label> </label>
<label className="text-xs font-medium text-slate-600"> <label className="text-xs font-medium text-slate-600">
Category Category
<CategorySelect value={category} onChange={setCategory} /> <CategorySelect value={category} onChange={setCategory} publishPublic={canPublishDirectly && publishDirectly} />
</label> </label>
<label className="text-xs font-medium text-slate-600"> <label className="text-xs font-medium text-slate-600">
Manufacturer Manufacturer
<input <ManufacturerSelect
value={manufacturer} value={manufacturerId}
onChange={(e) => setManufacturer(e.target.value)} onChange={setManufacturerId}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm" publishPublic={canPublishDirectly && publishDirectly}
/> />
</label> </label>
<label className="text-xs font-medium text-slate-600"> <label className="text-xs font-medium text-slate-600">
@@ -224,11 +258,28 @@ export default function DeviceTemplateEditor({
</label> </label>
</div> </div>
{canPublishDirectly && (
<label className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50/60 px-2 py-1.5 text-xs text-amber-800">
<input
type="checkbox"
checked={publishDirectly}
onChange={(e) => setPublishDirectly(e.target.checked)}
className="mt-0.5"
/>
<span>
<span className="font-medium">Publish directly to the shared library.</span> Skips the review queue —
use this when you're authoring the official catalog yourself. Any new category, manufacturer, or
connector type you create below while this is checked is published too, so the device doesn't end up
referencing something private.
</span>
</label>
)}
<div> <div>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Ports</h4> <h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Ports</h4>
<div className="space-y-1.5"> <div className="space-y-1.5">
{ports.map((port) => ( {ports.map((port) => (
<div key={port.key} className="flex items-center gap-1.5"> <div key={port.key} className="flex flex-wrap items-center gap-1.5">
<input <input
ref={(el) => { ref={(el) => {
if (el && focusKey === port.key) { if (el && focusKey === port.key) {
@@ -263,6 +314,20 @@ export default function DeviceTemplateEditor({
</option> </option>
))} ))}
</select> </select>
{/* Grouped so these three wrap to their own line as a unit
rather than each one reflowing separately if the row
ever gets tighter than it can fit. */}
<div className="ml-auto flex shrink-0 items-center gap-1">
<button
onClick={() => updatePort(port.key, { builtInCable: !port.builtInCable })}
tabIndex={-1}
title="Built-in cable"
className={`shrink-0 rounded px-1 ${
port.builtInCable ? 'bg-amber-100 opacity-100 ring-1 ring-amber-300' : 'opacity-30 hover:opacity-60'
}`}
>
🔌
</button>
<button <button
onClick={() => clonePortRow(port.key)} onClick={() => clonePortRow(port.key)}
tabIndex={-1} tabIndex={-1}
@@ -280,6 +345,7 @@ export default function DeviceTemplateEditor({
✕ ✕
</button> </button>
</div> </div>
</div>
))} ))}
{ports.length === 0 && <p className="text-[11px] italic text-slate-400">No ports yet.</p>} {ports.length === 0 && <p className="text-[11px] italic text-slate-400">No ports yet.</p>}
</div> </div>
@@ -316,18 +382,11 @@ export default function DeviceTemplateEditor({
))} ))}
</select> </select>
</div> </div>
<input <FamilySelect value={newPortTypeFamily} onChange={setNewPortTypeFamily} portTypes={portTypes} />
value={newPortTypeFamily} <p className="text-[10px] text-slate-500">Leave this as a new family and it'll default to the connector name.</p>
onChange={(e) => setNewPortTypeFamily(e.target.value)}
placeholder="Compatibility family (optional, defaults to name)"
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<p className="text-[10px] text-slate-500">
Two ports can be wired together only if their connector type shares the same family.
</p>
<div> <div>
<p className="text-[10px] font-medium text-slate-600"> <p className="text-[10px] font-medium text-slate-600">
Also compatible with (for jacks that accept more than one kind — e.g. a patch bay that's Can accept connections from (for jacks that accept more than one kind — e.g. a patch bay that's
balanced or unbalanced): balanced or unbalanced):
</p> </p>
<div className="mt-1 max-h-24 overflow-y-auto rounded border border-slate-200 bg-white p-1.5"> <div className="mt-1 max-h-24 overflow-y-auto rounded border border-slate-200 bg-white p-1.5">
@@ -376,6 +435,8 @@ export default function DeviceTemplateEditor({
? 'Submit for review' ? 'Submit for review'
: template : template
? 'Save changes' ? 'Save changes'
: canPublishDirectly && publishDirectly
? 'Publish device'
: 'Save device'} : 'Save device'}
</button> </button>
</div> </div>
+119 -25
View File
@@ -3,6 +3,7 @@ import { PORT_CATEGORIES } from '../../domain/constants'
import { allPortTypes, expandCompatibleFamilyIds } from '../../domain/diagram' import { allPortTypes, expandCompatibleFamilyIds } from '../../domain/diagram'
import type { PortType } from '../../domain/types' import type { PortType } from '../../domain/types'
import { portTypeToRow } from '../../data/catalogRowMapping' import { portTypeToRow } from '../../data/catalogRowMapping'
import FamilySelect from '../common/FamilySelect'
import { useAdminReviewStore } from '../../state/adminReviewStore' import { useAdminReviewStore } from '../../state/adminReviewStore'
import { useAuthStore } from '../../state/authStore' import { useAuthStore } from '../../state/authStore'
import { useCatalogStore } from '../../state/catalogStore' import { useCatalogStore } from '../../state/catalogStore'
@@ -11,21 +12,30 @@ import { useSubmissionStore } from '../../state/submissionStore'
export function PortTypeForm({ export function PortTypeForm({
portType, portType,
allTypes, allTypes,
showPublishToggle = false,
onCancel, onCancel,
onSave, onSave,
}: { }: {
portType: PortType /** Omitted when defining a brand-new port type — mirrors CableTypeForm's
* optional `initial`. */
portType?: PortType
allTypes: PortType[] allTypes: PortType[]
/** Shows a "Publish directly" checkbox and passes its value as onSave's
* second argument — only meaningful for an Admin defining a brand-new
* type (never set for edit/suggest-edit/admin-edit). */
showPublishToggle?: boolean
onCancel: () => void onCancel: () => void
onSave: (fields: Omit<PortType, 'id' | 'custom'>) => void onSave: (fields: Omit<PortType, 'id' | 'custom'>, publishPublic: boolean) => void
}) { }) {
const [name, setName] = useState(portType.name) const [name, setName] = useState(portType?.name ?? '')
const [category, setCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>(portType.category) const [category, setCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>(portType?.category ?? 'other')
const [family, setFamily] = useState(portType.family) const [family, setFamily] = useState(portType?.family ?? '')
const [alsoCompatibleWith, setAlsoCompatibleWith] = useState<Set<string>>(() => { const [alsoCompatibleWith, setAlsoCompatibleWith] = useState<Set<string>>(() => {
if (!portType) return new Set()
const currentFamilies = new Set([portType.family, ...(portType.compatibleFamilyIds ?? [])]) const currentFamilies = new Set([portType.family, ...(portType.compatibleFamilyIds ?? [])])
return new Set(allTypes.filter((pt) => pt.id !== portType.id && currentFamilies.has(pt.family)).map((pt) => pt.id)) return new Set(allTypes.filter((pt) => pt.id !== portType.id && currentFamilies.has(pt.family)).map((pt) => pt.id))
}) })
const [publishPublic, setPublishPublic] = useState(false)
const toggle = (id: string) => { const toggle = (id: string) => {
setAlsoCompatibleWith((prev) => { setAlsoCompatibleWith((prev) => {
@@ -36,18 +46,28 @@ export function PortTypeForm({
}) })
} }
const canSave = name.trim().length > 0
const handleSave = () => { const handleSave = () => {
if (!name.trim() || !family.trim()) return if (!canSave) return
// Mirrors DeviceTemplateEditor's inline connector-type panel: an empty
// family (never confirmed in the FamilySelect sub-form below) defaults
// to the connector's own name rather than silently blocking Save — a
// brand-new connector is its own family unless told otherwise.
const resolvedFamily = (family.trim() || name.trim()).toLowerCase().replace(/\s+/g, '-')
const compatibleFamilyIds = expandCompatibleFamilyIds([...alsoCompatibleWith], allTypes).filter( const compatibleFamilyIds = expandCompatibleFamilyIds([...alsoCompatibleWith], allTypes).filter(
(f) => f !== family.trim(), (f) => f !== resolvedFamily,
) )
onSave({ onSave(
{
name: name.trim(), name: name.trim(),
category, category,
family: family.trim(), family: resolvedFamily,
compatibleFamilyIds, compatibleFamilyIds,
maxConnections: portType.maxConnections, maxConnections: portType?.maxConnections,
}) },
publishPublic,
)
} }
return ( return (
@@ -70,17 +90,13 @@ export function PortTypeForm({
))} ))}
</select> </select>
</div> </div>
<input <FamilySelect value={family} onChange={setFamily} portTypes={allTypes} />
value={family} {!portType && <p className="text-[10px] text-slate-500">Leave this as a new family and it'll default to the connector name.</p>}
onChange={(e) => setFamily(e.target.value)}
placeholder="Compatibility family"
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<div> <div>
<p className="text-[10px] font-medium text-slate-600">Also compatible with:</p> <p className="text-[10px] font-medium text-slate-600">Can accept connections from:</p>
<div className="mt-1 max-h-32 overflow-y-auto rounded border border-slate-200 bg-white p-1.5"> <div className="mt-1 max-h-32 overflow-y-auto rounded border border-slate-200 bg-white p-1.5">
{allTypes {allTypes
.filter((pt) => pt.id !== portType.id) .filter((pt) => pt.id !== portType?.id)
.map((pt) => ( .map((pt) => (
<label key={pt.id} className="flex items-center gap-1.5 py-0.5 text-[11px] text-slate-600"> <label key={pt.id} className="flex items-center gap-1.5 py-0.5 text-[11px] text-slate-600">
<input type="checkbox" checked={alsoCompatibleWith.has(pt.id)} onChange={() => toggle(pt.id)} /> <input type="checkbox" checked={alsoCompatibleWith.has(pt.id)} onChange={() => toggle(pt.id)} />
@@ -89,13 +105,20 @@ export function PortTypeForm({
))} ))}
</div> </div>
</div> </div>
{showPublishToggle && (
<label className="flex items-center gap-1.5 text-[11px] text-slate-500">
<input type="checkbox" checked={publishPublic} onChange={(e) => setPublishPublic(e.target.checked)} />
Publish directly to the shared library (skips the review queue)
</label>
)}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button onClick={onCancel} className="text-[11px] text-slate-500"> <button onClick={onCancel} className="text-[11px] text-slate-500">
Cancel Cancel
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500" disabled={!canSave}
className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
> >
Save Save
</button> </button>
@@ -106,14 +129,19 @@ export function PortTypeForm({
export default function PortTypesPanel() { export default function PortTypesPanel() {
const catalog = useCatalogStore((s) => s.catalog) const catalog = useCatalogStore((s) => s.catalog)
const addCustomPortType = useCatalogStore((s) => s.addCustomPortType)
const updateCustomPortType = useCatalogStore((s) => s.updateCustomPortType) const updateCustomPortType = useCatalogStore((s) => s.updateCustomPortType)
const removeCustomPortType = useCatalogStore((s) => s.removeCustomPortType)
const adminUpdatePortType = useCatalogStore((s) => s.adminUpdatePortType) const adminUpdatePortType = useCatalogStore((s) => s.adminUpdatePortType)
const adminAddPortType = useCatalogStore((s) => s.adminAddPortType)
const adminRemovePortType = useCatalogStore((s) => s.adminRemovePortType)
const adminUnpublish = useCatalogStore((s) => s.adminUnpublish) const adminUnpublish = useCatalogStore((s) => s.adminUnpublish)
const mySubmissions = useSubmissionStore((s) => s.mySubmissions) const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
const submitForReview = useSubmissionStore((s) => s.submitForReview) const submitForReview = useSubmissionStore((s) => s.submitForReview)
const getUsageImpact = useAdminReviewStore((s) => s.getUsageImpact) const getUsageImpact = useAdminReviewStore((s) => s.getUsageImpact)
const role = useAuthStore((s) => s.role) const role = useAuthStore((s) => s.role)
const isAdmin = role === 'admin' || role === 'super_admin' const isAdmin = role === 'admin' || role === 'super_admin'
const [creating, setCreating] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [suggestingId, setSuggestingId] = useState<string | null>(null) const [suggestingId, setSuggestingId] = useState<string | null>(null)
const [adminEditingId, setAdminEditingId] = useState<string | null>(null) const [adminEditingId, setAdminEditingId] = useState<string | null>(null)
@@ -143,6 +171,12 @@ export default function PortTypesPanel() {
return names.length > 0 ? names.join(', ') : null return names.length > 0 ? names.join(', ') : null
} }
const handleCreate = (fields: Omit<PortType, 'id' | 'custom'>, publishPublic: boolean) => {
if (isAdmin && publishPublic) adminAddPortType(fields)
else addCustomPortType(fields)
setCreating(false)
}
const handleSubmitForPublic = (pt: PortType) => { const handleSubmitForPublic = (pt: PortType) => {
submitForReview('port_type', pt.id, portTypeToRow(pt)) submitForReview('port_type', pt.id, portTypeToRow(pt))
} }
@@ -168,15 +202,62 @@ export default function PortTypesPanel() {
adminUnpublish('port_type', pt.id) adminUnpublish('port_type', pt.id)
} }
// Shared by both the Custom (your own private type) and Built-in (Admin
// permanent-delete) delete flows — same two-stage check as
// CategoryManufacturerPanel's handleDelete: a device template's port
// still pointing at it would otherwise fail as a raw foreign-key
// violation, and nothing stops deleting one still used by a *placed*
// device in a diagram (diagrams are a JSONB blob, not relationally
// linked). Either kind of usage blocks the delete outright — unlike
// unpublish, a hard delete can't be undone.
const deleteIfUnused = async (pt: PortType, remove: (id: string) => Promise<void>) => {
const usedByTemplate = catalog.deviceTemplates.some((t) => t.ports.some((p) => p.portTypeId === pt.id))
if (usedByTemplate) {
window.alert(`Can't delete "${pt.name}" — it's still used by a device template's port.`)
return
}
const impact = await getUsageImpact('port_type', pt.id)
if (impact.diagramCount > 0) {
window.alert(
`Can't delete "${pt.name}" — used in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}.`,
)
return
}
const confirmed = window.confirm(`Delete "${pt.name}"? This can't be undone.`)
if (!confirmed) return
try {
await remove(pt.id)
} catch {
window.alert(`Couldn't delete "${pt.name}" — it may still be in use somewhere.`)
}
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500"> <div className="mb-1 flex items-center justify-between">
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Custom ({customTypes.length}) Custom ({customTypes.length})
</h4> </h4>
{customTypes.length === 0 ? ( {!creating && (
<button onClick={() => setCreating(true)} className="text-[11px] font-medium text-indigo-600 hover:underline">
+ Define a new port type
</button>
)}
</div>
{creating && (
<div className="mb-1.5">
<PortTypeForm
allTypes={allTypes}
showPublishToggle={isAdmin}
onCancel={() => setCreating(false)}
onSave={handleCreate}
/>
</div>
)}
{customTypes.length === 0 && !creating ? (
<p className="text-[11px] italic text-slate-400"> <p className="text-[11px] italic text-slate-400">
None yet — define one from the "+ Define a new connector type" link when adding a custom device. None yet — useful for a connector this app doesn't already have, like a proprietary jack.
</p> </p>
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -201,7 +282,7 @@ export default function PortTypesPanel() {
<div className="font-medium text-slate-700">{pt.name}</div> <div className="font-medium text-slate-700">{pt.name}</div>
<div className="truncate text-[10px] text-slate-400"> <div className="truncate text-[10px] text-slate-400">
family: {pt.family} family: {pt.family}
{describeCompatibility(pt) ? ` · also fits: ${describeCompatibility(pt)}` : ''} {describeCompatibility(pt) ? ` · can accept: ${describeCompatibility(pt)}` : ''}
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
@@ -224,6 +305,12 @@ export default function PortTypesPanel() {
> >
Edit Edit
</button> </button>
<button
onClick={() => deleteIfUnused(pt, removeCustomPortType)}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600"
>
Delete
</button>
</div> </div>
</div> </div>
), ),
@@ -265,7 +352,7 @@ export default function PortTypesPanel() {
<span className="min-w-0 truncate"> <span className="min-w-0 truncate">
{pt.name} {pt.name}
{describeCompatibility(pt) && ( {describeCompatibility(pt) && (
<span className="text-slate-400"> · also fits: {describeCompatibility(pt)}</span> <span className="text-slate-400"> · can accept: {describeCompatibility(pt)}</span>
)} )}
</span> </span>
{isAdmin ? ( {isAdmin ? (
@@ -282,6 +369,13 @@ export default function PortTypesPanel() {
> >
Unpublish Unpublish
</button> </button>
<button
onClick={() => deleteIfUnused(pt, adminRemovePortType)}
title="Permanently delete — only possible while nothing uses it"
className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-red-50 hover:text-red-600"
>
Delete
</button>
</div> </div>
) : pendingEntityIds.has(pt.id) ? ( ) : pendingEntityIds.has(pt.id) ? (
<span className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600"> <span className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600">
@@ -14,6 +14,7 @@ const ENTITY_TYPE_LABELS: Record<CatalogSubmission['entityType'], string> = {
port_type: 'Port type', port_type: 'Port type',
cable_type: 'Cable type', cable_type: 'Cable type',
device_category: 'Category', device_category: 'Category',
manufacturer: 'Manufacturer',
} }
function formatDate(iso: string): string { function formatDate(iso: string): string {
@@ -62,15 +63,36 @@ function SubmissionReviewCard({
return diffRows(currentRow, submission.proposedData) return diffRows(currentRow, submission.proposedData)
}, [currentEntity, submission.entityType, submission.proposedData]) }, [currentEntity, submission.entityType, submission.proposedData])
// Device names, categories, and manufacturers all benefit from the same
// nudge — a normalized manufacturer catalog only actually stays clean if
// near-duplicate manufacturer/category submissions get flagged the same
// way near-duplicate devices do (organized-ideas.md §3). Port/cable types
// aren't included — nothing so far has suggested they drift the same way.
const duplicates = useMemo(() => { const duplicates = useMemo(() => {
if (isEditToPublicEntry || submission.entityType !== 'device_template') return [] if (isEditToPublicEntry) return []
const proposedName = typeof submission.proposedData.name === 'string' ? submission.proposedData.name : '' const proposedName = typeof submission.proposedData.name === 'string' ? submission.proposedData.name : ''
if (!proposedName) return [] if (!proposedName) return []
const publicTemplates = catalog.deviceTemplates const candidates =
.filter((dt) => !dt.custom && dt.id !== submission.entityId) submission.entityType === 'device_template'
.map((dt) => ({ id: dt.id, name: dt.name })) ? catalog.deviceTemplates.filter((dt) => !dt.custom && dt.id !== submission.entityId)
return findLikelyDuplicates(proposedName, publicTemplates) : submission.entityType === 'device_category'
}, [isEditToPublicEntry, submission.entityType, submission.entityId, submission.proposedData, catalog.deviceTemplates]) ? catalog.deviceCategories.filter((c) => !c.custom && c.id !== submission.entityId)
: submission.entityType === 'manufacturer'
? catalog.manufacturers.filter((m) => !m.custom && m.id !== submission.entityId)
: []
return findLikelyDuplicates(
proposedName,
candidates.map((c) => ({ id: c.id, name: c.name })),
)
}, [
isEditToPublicEntry,
submission.entityType,
submission.entityId,
submission.proposedData,
catalog.deviceTemplates,
catalog.deviceCategories,
catalog.manufacturers,
])
const handleApprove = async () => { const handleApprove = async () => {
setBusy(true) setBusy(true)
@@ -17,6 +17,7 @@ const ENTITY_TYPE_LABELS: Record<CatalogSubmission['entityType'], string> = {
port_type: 'Port type', port_type: 'Port type',
cable_type: 'Cable type', cable_type: 'Cable type',
device_category: 'Category', device_category: 'Category',
manufacturer: 'Manufacturer',
} }
/** Best-effort display name: the entity's current live name if it still /** Best-effort display name: the entity's current live name if it still
@@ -69,7 +70,7 @@ export default function MySubmissionsModal({ onClose }: { onClose: () => void })
const withdraw = useSubmissionStore((s) => s.withdraw) const withdraw = useSubmissionStore((s) => s.withdraw)
const resubmit = useSubmissionStore((s) => s.resubmit) const resubmit = useSubmissionStore((s) => s.resubmit)
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [categoryDraft, setCategoryDraft] = useState('') const [nameDraft, setNameDraft] = useState('')
const allPortTypesList = allPortTypes(catalog) const allPortTypesList = allPortTypes(catalog)
@@ -80,7 +81,7 @@ export default function MySubmissionsModal({ onClose }: { onClose: () => void })
} }
const startEditing = (submission: CatalogSubmission) => { const startEditing = (submission: CatalogSubmission) => {
setCategoryDraft(typeof submission.proposedData.name === 'string' ? submission.proposedData.name : '') setNameDraft(typeof submission.proposedData.name === 'string' ? submission.proposedData.name : '')
setEditingId(submission.id) setEditingId(submission.id)
} }
@@ -188,13 +189,13 @@ export default function MySubmissionsModal({ onClose }: { onClose: () => void })
/> />
) )
} }
if (isEditing && submission.entityType === 'device_category') { if (isEditing && (submission.entityType === 'device_category' || submission.entityType === 'manufacturer')) {
return ( return (
<div key={submission.id} className="flex items-center gap-1.5 rounded border border-indigo-200 bg-indigo-50/40 p-2"> <div key={submission.id} className="flex items-center gap-1.5 rounded border border-indigo-200 bg-indigo-50/40 p-2">
<input <input
autoFocus autoFocus
value={categoryDraft} value={nameDraft}
onChange={(e) => setCategoryDraft(e.target.value)} onChange={(e) => setNameDraft(e.target.value)}
className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs" className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
/> />
<button onClick={() => setEditingId(null)} className="shrink-0 text-[11px] text-slate-500"> <button onClick={() => setEditingId(null)} className="shrink-0 text-[11px] text-slate-500">
@@ -202,8 +203,8 @@ export default function MySubmissionsModal({ onClose }: { onClose: () => void })
</button> </button>
<button <button
onClick={() => { onClick={() => {
if (!categoryDraft.trim()) return if (!nameDraft.trim()) return
resubmit(submission.id, { name: categoryDraft.trim() }) resubmit(submission.id, { name: nameDraft.trim() })
setEditingId(null) setEditingId(null)
}} }}
className="shrink-0 rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500" className="shrink-0 rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500"
@@ -1,8 +1,8 @@
import { cableTypeToRow, deviceCategoryToRow, deviceTemplateToRow, portTypeToRow } from '../../data/catalogRowMapping' import { cableTypeToRow, deviceCategoryToRow, deviceTemplateToRow, manufacturerToRow, portTypeToRow } from '../../data/catalogRowMapping'
import type { CatalogEntityType } from '../../data/SubmissionRepository' import type { CatalogEntityType } from '../../data/SubmissionRepository'
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, PortType } from '../../domain/types' import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, Manufacturer, PortType } from '../../domain/types'
export type CatalogEntity = PortType | CableType | DeviceTemplate | DeviceCategoryDef export type CatalogEntity = PortType | CableType | DeviceTemplate | DeviceCategoryDef | Manufacturer
/** Looks up a submission's entity in the current catalog (public or your /** Looks up a submission's entity in the current catalog (public or your
* own private) by (entityType, entityId) — shared by MySubmissionsModal * own private) by (entityType, entityId) — shared by MySubmissionsModal
@@ -18,6 +18,8 @@ export function findCatalogEntity(catalog: Catalog, entityType: CatalogEntityTyp
return catalog.deviceTemplates.find((dt) => dt.id === entityId) return catalog.deviceTemplates.find((dt) => dt.id === entityId)
case 'device_category': case 'device_category':
return catalog.deviceCategories.find((c) => c.id === entityId) return catalog.deviceCategories.find((c) => c.id === entityId)
case 'manufacturer':
return catalog.manufacturers.find((m) => m.id === entityId)
} }
} }
@@ -34,5 +36,7 @@ export function catalogEntityToRow(entityType: CatalogEntityType, entity: Catalo
return deviceTemplateToRow(entity as DeviceTemplate) return deviceTemplateToRow(entity as DeviceTemplate)
case 'device_category': case 'device_category':
return deviceCategoryToRow(entity.name) return deviceCategoryToRow(entity.name)
case 'manufacturer':
return manufacturerToRow(entity.name)
} }
} }
+2 -2
View File
@@ -26,11 +26,11 @@ const FIELD_LABELS: Record<string, string> = {
category_id: 'Category', category_id: 'Category',
family: 'Family', family: 'Family',
family2: 'Family (end 2)', family2: 'Family (end 2)',
compatible_family_ids: 'Also compatible with', compatible_family_ids: 'Can accept connections from',
max_connections: 'Max connections', max_connections: 'Max connections',
unit: 'Unit', unit: 'Unit',
cost_per_unit: 'Cost per unit', cost_per_unit: 'Cost per unit',
manufacturer: 'Manufacturer', manufacturer_id: 'Manufacturer',
model: 'Model', model: 'Model',
cost: 'Cost', cost: 'Cost',
ports: 'Ports', ports: 'Ports',
+50 -1
View File
@@ -1,5 +1,5 @@
import type { CatalogEntityType } from './SubmissionRepository' import type { CatalogEntityType } from './SubmissionRepository'
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, PortType } from '../domain/types' import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, Manufacturer, PortType } from '../domain/types'
/** /**
* Storage abstraction for the global port/cable/category/device-template * Storage abstraction for the global port/cable/category/device-template
@@ -22,26 +22,75 @@ export interface CatalogRepository {
load(): Promise<Catalog> load(): Promise<Catalog>
addDeviceCategory(name: string): Promise<DeviceCategoryDef> addDeviceCategory(name: string): Promise<DeviceCategoryDef>
updateDeviceCategory(id: string, name: string): Promise<void>
/** Throws (rather than swallowing, like most methods here) if the delete
* is rejected — most commonly a foreign-key violation because one of
* your device templates still references this category, which the
* caller needs to know about to tell you why nothing happened. */
removeDeviceCategory(id: string): Promise<void>
addManufacturer(name: string): Promise<Manufacturer>
updateManufacturer(id: string, name: string): Promise<void>
/** Same throw-on-failure contract as removeDeviceCategory. */
removeManufacturer(id: string): Promise<void>
addPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType> addPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType>
updatePortType(id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>): Promise<void> updatePortType(id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>): Promise<void>
/** Same throw-on-failure contract as removeDeviceCategory — most commonly
* a foreign-key violation because some device template's port still
* references it. */
removePortType(id: string): Promise<void>
addCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType> addCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType>
updateCableType(id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>): Promise<void> updateCableType(id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>): Promise<void>
removeCableType(id: string): Promise<void>
addDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate> addDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate>
updateDeviceTemplate(id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>): Promise<void> updateDeviceTemplate(id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>): Promise<void>
removeDeviceTemplate(id: string): Promise<void> removeDeviceTemplate(id: string): Promise<void>
/** Admin/Super-Admin direct-to-public creation — skips the submission/
* review workflow entirely, for authoring the shared library yourself
* (e.g. building out the initial device catalog) rather than reviewing
* someone else's proposal. Same RLS branch that already allows an Admin
* to directly edit a public entry (`is_admin() and is_public`) also
* covers inserting one — unlike adminUpdate* below, this is a genuinely
* different write, not just an alias (is_public: true, owner_id: null
* from the start). */
adminAddDeviceCategory(name: string): Promise<DeviceCategoryDef>
adminAddManufacturer(name: string): Promise<Manufacturer>
adminAddPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType>
adminAddCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType>
adminAddDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate>
/** Admin/Super-Admin direct edit of an already-public port/cable/device /** Admin/Super-Admin direct edit of an already-public port/cable/device
* entry — same underlying write as approving a submission, minus the * entry — same underlying write as approving a submission, minus the
* submission. */ * submission. */
adminUpdatePortType(id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>): Promise<void> adminUpdatePortType(id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>): Promise<void>
adminUpdateCableType(id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>): Promise<void> adminUpdateCableType(id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>): Promise<void>
adminUpdateDeviceTemplate(id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>): Promise<void> adminUpdateDeviceTemplate(id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>): Promise<void>
adminUpdateDeviceCategory(id: string, name: string): Promise<void>
adminUpdateManufacturer(id: string, name: string): Promise<void>
/** Unpublishes a public entry (is_public -> false) rather than deleting /** Unpublishes a public entry (is_public -> false) rather than deleting
* it — organized-ideas.md §3's "public catalog entries are never hard- * it — organized-ideas.md §3's "public catalog entries are never hard-
* deleted, only hidden/unpublished". Existing diagrams that reference it * deleted, only hidden/unpublished". Existing diagrams that reference it
* by id are unaffected; it just stops being offered for new use. */ * by id are unaffected; it just stops being offered for new use. */
adminUnpublish(entityType: CatalogEntityType, id: string): Promise<void> adminUnpublish(entityType: CatalogEntityType, id: string): Promise<void>
/** Admin/Super-Admin permanent delete of a built-in (public) entry —
* narrower than adminUnpublish: the caller is expected to have already
* verified zero usage (no diagram references it, nothing else in the
* catalog does either) before calling this, same gate already applied to
* a regular user deleting their own private entry. Doesn't relax
* organized-ideas.md §3's "never break an existing diagram" principle —
* it only ever removes something genuinely unused (e.g. a mistake made
* while authoring the library), which is why this exists alongside
* adminUnpublish rather than instead of it. Same RLS branch as the other
* admin* methods (`is_admin()`), so these are aliases of the plain
* remove* methods above, not separate logic. Throws on failure. */
adminRemoveDeviceCategory(id: string): Promise<void>
adminRemoveManufacturer(id: string): Promise<void>
adminRemovePortType(id: string): Promise<void>
adminRemoveCableType(id: string): Promise<void>
adminRemoveDeviceTemplate(id: string): Promise<void>
} }
+2 -4
View File
@@ -1,8 +1,6 @@
/** Which catalog table a submission is about. Mirrors `catalog_submissions.entity_type`'s /** Which catalog table a submission is about. Mirrors `catalog_submissions.entity_type`'s
* check constraint — `manufacturer` is omitted since the app doesn't wire up the * check constraint. */
* normalized manufacturer catalog yet (see organized-ideas.md §3, deferred alongside export type CatalogEntityType = 'device_template' | 'port_type' | 'cable_type' | 'device_category' | 'manufacturer'
* device_templates.manufacturer staying free text for now). */
export type CatalogEntityType = 'device_template' | 'port_type' | 'cable_type' | 'device_category'
/** /**
* A user's request to change the public catalog — either promoting one of * A user's request to change the public catalog — either promoting one of
+172 -27
View File
@@ -1,5 +1,5 @@
import { v4 as uuid } from 'uuid' import { v4 as uuid } from 'uuid'
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, Port, PortType } from '../domain/types' import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, Manufacturer, Port, PortType } from '../domain/types'
import { CATALOG_TABLE_BY_ENTITY_TYPE, cableTypeToRow, deviceTemplateToRow, portTypeToRow } from './catalogRowMapping' import { CATALOG_TABLE_BY_ENTITY_TYPE, cableTypeToRow, deviceTemplateToRow, portTypeToRow } from './catalogRowMapping'
import type { CatalogRepository } from './CatalogRepository' import type { CatalogRepository } from './CatalogRepository'
import type { CatalogEntityType } from './SubmissionRepository' import type { CatalogEntityType } from './SubmissionRepository'
@@ -11,6 +11,12 @@ interface DeviceCategoryRow {
is_public: boolean is_public: boolean
} }
interface ManufacturerRow {
id: string
name: string
is_public: boolean
}
interface PortTypeRow { interface PortTypeRow {
id: string id: string
name: string name: string
@@ -35,7 +41,7 @@ interface DeviceTemplateRow {
id: string id: string
name: string name: string
category_id: string category_id: string
manufacturer: string | null manufacturer_id: string | null
model: string | null model: string | null
cost: number | null cost: number | null
is_public: boolean is_public: boolean
@@ -48,12 +54,17 @@ interface DeviceTemplatePortRow {
direction: Port['direction'] direction: Port['direction']
port_type_id: string port_type_id: string
sort_order: number sort_order: number
built_in_cable: boolean
} }
function toDeviceCategory(row: DeviceCategoryRow): DeviceCategoryDef { function toDeviceCategory(row: DeviceCategoryRow): DeviceCategoryDef {
return { id: row.id, name: row.name, custom: !row.is_public } return { id: row.id, name: row.name, custom: !row.is_public }
} }
function toManufacturer(row: ManufacturerRow): Manufacturer {
return { id: row.id, name: row.name, custom: !row.is_public }
}
function toPortType(row: PortTypeRow): PortType { function toPortType(row: PortTypeRow): PortType {
return { return {
id: row.id, id: row.id,
@@ -83,13 +94,13 @@ function toDeviceTemplate(row: DeviceTemplateRow, portRows: DeviceTemplatePortRo
id: row.id, id: row.id,
name: row.name, name: row.name,
category: row.category_id, category: row.category_id,
manufacturer: row.manufacturer ?? undefined, manufacturerId: row.manufacturer_id ?? undefined,
model: row.model ?? undefined, model: row.model ?? undefined,
cost: row.cost ?? undefined, cost: row.cost ?? undefined,
ports: portRows ports: portRows
.filter((p) => p.device_template_id === row.id) .filter((p) => p.device_template_id === row.id)
.sort((a, b) => a.sort_order - b.sort_order) .sort((a, b) => a.sort_order - b.sort_order)
.map((p) => ({ id: p.id, name: p.name, direction: p.direction, portTypeId: p.port_type_id })), .map((p) => ({ id: p.id, name: p.name, direction: p.direction, portTypeId: p.port_type_id, builtInCable: p.built_in_cable })),
custom: !row.is_public, custom: !row.is_public,
} }
} }
@@ -105,8 +116,9 @@ function toDeviceTemplate(row: DeviceTemplateRow, portRows: DeviceTemplatePortRo
*/ */
export class SupabaseCatalogRepository implements CatalogRepository { export class SupabaseCatalogRepository implements CatalogRepository {
async load(): Promise<Catalog> { async load(): Promise<Catalog> {
const [categories, portTypes, cableTypes, templates, templatePorts] = await Promise.all([ const [categories, manufacturers, portTypes, cableTypes, templates, templatePorts] = await Promise.all([
supabase.from('device_categories').select('id, name, is_public').order('name'), supabase.from('device_categories').select('id, name, is_public').order('name'),
supabase.from('manufacturers').select('id, name, is_public').order('name'),
supabase supabase
.from('port_types') .from('port_types')
.select('id, name, category, family, compatible_family_ids, max_connections, is_public') .select('id, name, category, family, compatible_family_ids, max_connections, is_public')
@@ -115,12 +127,15 @@ export class SupabaseCatalogRepository implements CatalogRepository {
.from('cable_types') .from('cable_types')
.select('id, name, family, family2, unit, cost_per_unit, is_public') .select('id, name, family, family2, unit, cost_per_unit, is_public')
.order('name'), .order('name'),
supabase.from('device_templates').select('id, name, category_id, manufacturer, model, cost, is_public').order('name'), supabase.from('device_templates').select('id, name, category_id, manufacturer_id, model, cost, is_public').order('name'),
supabase.from('device_template_ports').select('id, device_template_id, name, direction, port_type_id, sort_order'), supabase
.from('device_template_ports')
.select('id, device_template_id, name, direction, port_type_id, sort_order, built_in_cable'),
]) ])
for (const [label, result] of [ for (const [label, result] of [
['device categories', categories], ['device categories', categories],
['manufacturers', manufacturers],
['port types', portTypes], ['port types', portTypes],
['cable types', cableTypes], ['cable types', cableTypes],
['device templates', templates], ['device templates', templates],
@@ -132,6 +147,7 @@ export class SupabaseCatalogRepository implements CatalogRepository {
const portRows = (templatePorts.data ?? []) as DeviceTemplatePortRow[] const portRows = (templatePorts.data ?? []) as DeviceTemplatePortRow[]
return { return {
deviceCategories: ((categories.data ?? []) as DeviceCategoryRow[]).map(toDeviceCategory), deviceCategories: ((categories.data ?? []) as DeviceCategoryRow[]).map(toDeviceCategory),
manufacturers: ((manufacturers.data ?? []) as ManufacturerRow[]).map(toManufacturer),
portTypes: ((portTypes.data ?? []) as PortTypeRow[]).map(toPortType), portTypes: ((portTypes.data ?? []) as PortTypeRow[]).map(toPortType),
cableTypes: ((cableTypes.data ?? []) as CableTypeRow[]).map(toCableType), cableTypes: ((cableTypes.data ?? []) as CableTypeRow[]).map(toCableType),
deviceTemplates: ((templates.data ?? []) as DeviceTemplateRow[]).map((row) => toDeviceTemplate(row, portRows)), deviceTemplates: ((templates.data ?? []) as DeviceTemplateRow[]).map((row) => toDeviceTemplate(row, portRows)),
@@ -145,24 +161,81 @@ export class SupabaseCatalogRepository implements CatalogRepository {
return user?.id ?? null return user?.id ?? null
} }
async addDeviceCategory(name: string): Promise<DeviceCategoryDef> { private async insertDeviceCategory(name: string, isPublic: boolean, ownerId: string | null): Promise<DeviceCategoryDef> {
const ownerId = await this.currentUserId()
const id = uuid() const id = uuid()
const { error } = await supabase const { error } = await supabase.from('device_categories').insert({ id, name, is_public: isPublic, owner_id: ownerId })
.from('device_categories')
.insert({ id, name, is_public: false, owner_id: ownerId })
if (error) console.error('Failed to add device category to Supabase', error) if (error) console.error('Failed to add device category to Supabase', error)
return { id, name, custom: true } return { id, name, custom: !isPublic }
} }
async addPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType> { async addDeviceCategory(name: string): Promise<DeviceCategoryDef> {
const ownerId = await this.currentUserId() return this.insertDeviceCategory(name, false, await this.currentUserId())
}
adminAddDeviceCategory(name: string): Promise<DeviceCategoryDef> {
return this.insertDeviceCategory(name, true, null)
}
async updateDeviceCategory(id: string, name: string): Promise<void> {
const { error } = await supabase.from('device_categories').update({ name }).eq('id', id)
if (error) console.error('Failed to update device category in Supabase', error)
}
async removeDeviceCategory(id: string): Promise<void> {
const { error } = await supabase.from('device_categories').delete().eq('id', id)
if (error) {
console.error('Failed to remove device category from Supabase', error)
throw error
}
}
private async insertManufacturer(name: string, isPublic: boolean, ownerId: string | null): Promise<Manufacturer> {
const id = uuid()
const { error } = await supabase.from('manufacturers').insert({ id, name, is_public: isPublic, owner_id: ownerId })
if (error) console.error('Failed to add manufacturer to Supabase', error)
return { id, name, custom: !isPublic }
}
async addManufacturer(name: string): Promise<Manufacturer> {
return this.insertManufacturer(name, false, await this.currentUserId())
}
adminAddManufacturer(name: string): Promise<Manufacturer> {
return this.insertManufacturer(name, true, null)
}
async updateManufacturer(id: string, name: string): Promise<void> {
const { error } = await supabase.from('manufacturers').update({ name }).eq('id', id)
if (error) console.error('Failed to update manufacturer in Supabase', error)
}
async removeManufacturer(id: string): Promise<void> {
const { error } = await supabase.from('manufacturers').delete().eq('id', id)
if (error) {
console.error('Failed to remove manufacturer from Supabase', error)
throw error
}
}
private async insertPortType(
portType: Omit<PortType, 'id' | 'custom'>,
isPublic: boolean,
ownerId: string | null,
): Promise<PortType> {
const id = uuid() const id = uuid()
const { error } = await supabase const { error } = await supabase
.from('port_types') .from('port_types')
.insert({ id, ...portTypeToRow(portType), is_public: false, owner_id: ownerId }) .insert({ id, ...portTypeToRow(portType), is_public: isPublic, owner_id: ownerId })
if (error) console.error('Failed to add port type to Supabase', error) if (error) console.error('Failed to add port type to Supabase', error)
return { ...portType, id, custom: true } return { ...portType, id, custom: !isPublic }
}
async addPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType> {
return this.insertPortType(portType, false, await this.currentUserId())
}
adminAddPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType> {
return this.insertPortType(portType, true, null)
} }
async updatePortType(id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>): Promise<void> { async updatePortType(id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>): Promise<void> {
@@ -176,14 +249,33 @@ export class SupabaseCatalogRepository implements CatalogRepository {
if (error) console.error('Failed to update port type in Supabase', error) if (error) console.error('Failed to update port type in Supabase', error)
} }
async addCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType> { async removePortType(id: string): Promise<void> {
const ownerId = await this.currentUserId() const { error } = await supabase.from('port_types').delete().eq('id', id)
if (error) {
console.error('Failed to remove port type from Supabase', error)
throw error
}
}
private async insertCableType(
cableType: Omit<CableType, 'id' | 'custom'>,
isPublic: boolean,
ownerId: string | null,
): Promise<CableType> {
const id = uuid() const id = uuid()
const { error } = await supabase const { error } = await supabase
.from('cable_types') .from('cable_types')
.insert({ id, ...cableTypeToRow(cableType), is_public: false, owner_id: ownerId }) .insert({ id, ...cableTypeToRow(cableType), is_public: isPublic, owner_id: ownerId })
if (error) console.error('Failed to add cable type to Supabase', error) if (error) console.error('Failed to add cable type to Supabase', error)
return { ...cableType, id, custom: true } return { ...cableType, id, custom: !isPublic }
}
async addCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType> {
return this.insertCableType(cableType, false, await this.currentUserId())
}
adminAddCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType> {
return this.insertCableType(cableType, true, null)
} }
async updateCableType(id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>): Promise<void> { async updateCableType(id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>): Promise<void> {
@@ -197,14 +289,25 @@ export class SupabaseCatalogRepository implements CatalogRepository {
if (error) console.error('Failed to update cable type in Supabase', error) if (error) console.error('Failed to update cable type in Supabase', error)
} }
async addDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate> { async removeCableType(id: string): Promise<void> {
const ownerId = await this.currentUserId() const { error } = await supabase.from('cable_types').delete().eq('id', id)
if (error) {
console.error('Failed to remove cable type from Supabase', error)
throw error
}
}
private async insertDeviceTemplate(
template: Omit<DeviceTemplate, 'id' | 'custom'>,
isPublic: boolean,
ownerId: string | null,
): Promise<DeviceTemplate> {
const id = uuid() const id = uuid()
const { ports: _ports, ...templateRow } = deviceTemplateToRow(template) const { ports: _ports, ...templateRow } = deviceTemplateToRow(template)
const { error } = await supabase.from('device_templates').insert({ id, ...templateRow, is_public: false, owner_id: ownerId }) const { error } = await supabase.from('device_templates').insert({ id, ...templateRow, is_public: isPublic, owner_id: ownerId })
if (error) { if (error) {
console.error('Failed to add device template to Supabase', error) console.error('Failed to add device template to Supabase', error)
return { ...template, id, custom: true } return { ...template, id, custom: !isPublic }
} }
const ports = template.ports.map((port, index) => ({ ...port, id: uuid(), sortOrder: index })) const ports = template.ports.map((port, index) => ({ ...port, id: uuid(), sortOrder: index }))
if (ports.length > 0) { if (ports.length > 0) {
@@ -216,18 +319,27 @@ export class SupabaseCatalogRepository implements CatalogRepository {
direction: port.direction, direction: port.direction,
port_type_id: port.portTypeId, port_type_id: port.portTypeId,
sort_order: port.sortOrder, sort_order: port.sortOrder,
built_in_cable: port.builtInCable ?? false,
})), })),
) )
if (portsError) console.error('Failed to add device template ports to Supabase', portsError) if (portsError) console.error('Failed to add device template ports to Supabase', portsError)
} }
return { ...template, id, ports, custom: true } return { ...template, id, ports, custom: !isPublic }
}
async addDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate> {
return this.insertDeviceTemplate(template, false, await this.currentUserId())
}
adminAddDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate> {
return this.insertDeviceTemplate(template, true, null)
} }
async updateDeviceTemplate(id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>): Promise<void> { async updateDeviceTemplate(id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>): Promise<void> {
const payload: Record<string, unknown> = {} const payload: Record<string, unknown> = {}
if (patch.name !== undefined) payload.name = patch.name if (patch.name !== undefined) payload.name = patch.name
if (patch.category !== undefined) payload.category_id = patch.category if (patch.category !== undefined) payload.category_id = patch.category
if (patch.manufacturer !== undefined) payload.manufacturer = patch.manufacturer if (patch.manufacturerId !== undefined) payload.manufacturer_id = patch.manufacturerId
if (patch.model !== undefined) payload.model = patch.model if (patch.model !== undefined) payload.model = patch.model
if (patch.cost !== undefined) payload.cost = patch.cost if (patch.cost !== undefined) payload.cost = patch.cost
if (Object.keys(payload).length > 0) { if (Object.keys(payload).length > 0) {
@@ -250,6 +362,7 @@ export class SupabaseCatalogRepository implements CatalogRepository {
direction: port.direction, direction: port.direction,
port_type_id: port.portTypeId, port_type_id: port.portTypeId,
sort_order: index, sort_order: index,
built_in_cable: port.builtInCable ?? false,
})), })),
) )
if (insertError) console.error('Failed to replace device template ports in Supabase', insertError) if (insertError) console.error('Failed to replace device template ports in Supabase', insertError)
@@ -280,9 +393,41 @@ export class SupabaseCatalogRepository implements CatalogRepository {
return this.updateDeviceTemplate(id, patch) return this.updateDeviceTemplate(id, patch)
} }
adminUpdateDeviceCategory(id: string, name: string): Promise<void> {
return this.updateDeviceCategory(id, name)
}
adminUpdateManufacturer(id: string, name: string): Promise<void> {
return this.updateManufacturer(id, name)
}
async adminUnpublish(entityType: CatalogEntityType, id: string): Promise<void> { async adminUnpublish(entityType: CatalogEntityType, id: string): Promise<void> {
const table = CATALOG_TABLE_BY_ENTITY_TYPE[entityType] const table = CATALOG_TABLE_BY_ENTITY_TYPE[entityType]
const { error } = await supabase.from(table).update({ is_public: false }).eq('id', id) const { error } = await supabase.from(table).update({ is_public: false }).eq('id', id)
if (error) console.error('Failed to unpublish catalog entry in Supabase', error) if (error) console.error('Failed to unpublish catalog entry in Supabase', error)
} }
adminRemoveDeviceCategory(id: string): Promise<void> {
return this.removeDeviceCategory(id)
}
adminRemoveManufacturer(id: string): Promise<void> {
return this.removeManufacturer(id)
}
adminRemovePortType(id: string): Promise<void> {
return this.removePortType(id)
}
adminRemoveCableType(id: string): Promise<void> {
return this.removeCableType(id)
}
async adminRemoveDeviceTemplate(id: string): Promise<void> {
const { error } = await supabase.from('device_templates').delete().eq('id', id)
if (error) {
console.error('Failed to remove device template from Supabase', error)
throw error
}
}
} }
+16 -3
View File
@@ -19,6 +19,7 @@ export const CATALOG_TABLE_BY_ENTITY_TYPE: Record<CatalogEntityType, string> = {
port_type: 'port_types', port_type: 'port_types',
cable_type: 'cable_types', cable_type: 'cable_types',
device_category: 'device_categories', device_category: 'device_categories',
manufacturer: 'manufacturers',
} }
export function portTypeToRow(portType: Omit<PortType, 'id' | 'custom'>): Record<string, unknown> { export function portTypeToRow(portType: Omit<PortType, 'id' | 'custom'>): Record<string, unknown> {
@@ -45,11 +46,15 @@ export function deviceCategoryToRow(name: string): Record<string, unknown> {
return { name } return { name }
} }
export function manufacturerToRow(name: string): Record<string, unknown> {
return { name }
}
export function deviceTemplateToRow(template: Omit<DeviceTemplate, 'id' | 'custom'>): Record<string, unknown> { export function deviceTemplateToRow(template: Omit<DeviceTemplate, 'id' | 'custom'>): Record<string, unknown> {
return { return {
name: template.name, name: template.name,
category_id: template.category, category_id: template.category,
manufacturer: template.manufacturer ?? null, manufacturer_id: template.manufacturerId ?? null,
model: template.model ?? null, model: template.model ?? null,
cost: template.cost ?? null, cost: template.cost ?? null,
ports: template.ports.map((port, index) => ({ ports: template.ports.map((port, index) => ({
@@ -57,6 +62,7 @@ export function deviceTemplateToRow(template: Omit<DeviceTemplate, 'id' | 'custo
direction: port.direction, direction: port.direction,
port_type_id: port.portTypeId, port_type_id: port.portTypeId,
sort_order: index, sort_order: index,
built_in_cable: port.builtInCable ?? false,
})), })),
} }
} }
@@ -92,11 +98,17 @@ export function rowToCableTypeFields(row: Record<string, unknown>): Omit<CableTy
} }
export function rowToDeviceTemplateFields(row: Record<string, unknown>): Omit<DeviceTemplate, 'id' | 'custom'> { export function rowToDeviceTemplateFields(row: Record<string, unknown>): Omit<DeviceTemplate, 'id' | 'custom'> {
const ports = (row.ports as Array<{ name: string; direction: DeviceTemplate['ports'][number]['direction']; port_type_id: string }>) ?? [] const ports =
(row.ports as Array<{
name: string
direction: DeviceTemplate['ports'][number]['direction']
port_type_id: string
built_in_cable?: boolean
}>) ?? []
return { return {
name: row.name as string, name: row.name as string,
category: row.category_id as string, category: row.category_id as string,
manufacturer: (row.manufacturer as string | null) ?? undefined, manufacturerId: (row.manufacturer_id as string | null) ?? undefined,
model: (row.model as string | null) ?? undefined, model: (row.model as string | null) ?? undefined,
cost: (row.cost as number | null) ?? undefined, cost: (row.cost as number | null) ?? undefined,
ports: ports.map((port, index) => ({ ports: ports.map((port, index) => ({
@@ -104,6 +116,7 @@ export function rowToDeviceTemplateFields(row: Record<string, unknown>): Omit<De
name: port.name, name: port.name,
direction: port.direction, direction: port.direction,
portTypeId: port.port_type_id, portTypeId: port.port_type_id,
builtInCable: port.built_in_cable ?? false,
})), })),
} }
} }
+24 -3
View File
@@ -1,4 +1,4 @@
import { allCableTypes } from './diagram' import { allCableTypes, manufacturerName } from './diagram'
import type { CableType, Catalog, Connection, Device, Diagram } from './types' import type { CableType, Catalog, Connection, Device, Diagram } from './types'
/** One length within a cable type's group — e.g. "3 × 10ft". `distance` /** One length within a cable type's group — e.g. "3 × 10ft". `distance`
@@ -59,7 +59,7 @@ export interface Bom {
} }
function deviceGroupKey(device: Device): string { function deviceGroupKey(device: Device): string {
return device.templateId ?? `${device.category}:${device.manufacturer ?? ''}:${device.model ?? device.name}` return device.templateId ?? `${device.category}:${device.manufacturerId ?? ''}:${device.model ?? device.name}`
} }
/** A connection's cost for BOM purposes: its own override if set, else /** A connection's cost for BOM purposes: its own override if set, else
@@ -82,8 +82,25 @@ function connectionCost(connection: Connection, cableType: CableType | undefined
return undefined return undefined
} }
/** Every port id, across all devices, with a built-in/captive cable — a
* connection touching either end of one doesn't need a cable purchased
* (organized-ideas.md-adjacent decision: the cable's already paid for as
* part of the device, like a keyboard's USB cable or a budget AVR's power
* cord). Built once per computeBom call rather than searched per
* connection. */
function builtInPortIds(devices: Device[]): Set<string> {
const ids = new Set<string>()
for (const device of devices) {
for (const port of device.ports) {
if (port.builtInCable) ids.add(port.id)
}
}
return ids
}
export function computeBom(diagram: Diagram, catalog: Catalog): Bom { export function computeBom(diagram: Diagram, catalog: Catalog): Bom {
const cableTypes = new Map(allCableTypes(catalog).map((ct) => [ct.id, ct])) const cableTypes = new Map(allCableTypes(catalog).map((ct) => [ct.id, ct]))
const builtIn = builtInPortIds(diagram.devices)
const cableLines = new Map<string, BomCableLine>() const cableLines = new Map<string, BomCableLine>()
// Nested by cableTypeId, then by distance (undefined key for "no length // Nested by cableTypeId, then by distance (undefined key for "no length
@@ -93,6 +110,10 @@ export function computeBom(diagram: Diagram, catalog: Catalog): Bom {
const lengthLines = new Map<string, Map<number | undefined, BomCableLengthLine>>() const lengthLines = new Map<string, Map<number | undefined, BomCableLengthLine>>()
for (const connection of diagram.connections) { for (const connection of diagram.connections) {
// A built-in cable is already paid for as part of the device — no line
// for it here, not even a "missing cost" note; the canvas is where this
// connection's built-in-ness is visible, not the shopping list.
if (builtIn.has(connection.sourcePortId) || builtIn.has(connection.targetPortId)) continue
const cableType = cableTypes.get(connection.cableTypeId) const cableType = cableTypes.get(connection.cableTypeId)
const name = cableType?.name ?? 'Unknown Cable' const name = cableType?.name ?? 'Unknown Cable'
const unit = cableType?.unit ?? 'ft' const unit = cableType?.unit ?? 'ft'
@@ -161,7 +182,7 @@ export function computeBom(diagram: Diagram, catalog: Catalog): Bom {
key, key,
name: device.name, name: device.name,
category: device.category, category: device.category,
manufacturer: device.manufacturer, manufacturer: manufacturerName(catalog, device.manufacturerId),
model: device.model, model: device.model,
count: 0, count: 0,
totalCost: 0, totalCost: 0,
+26 -2
View File
@@ -1,5 +1,5 @@
import { v4 as uuid } from 'uuid' import { v4 as uuid } from 'uuid'
import type { CableType, Catalog, Device, DeviceCategoryDef, Diagram, DeviceTemplate, PortType } from './types' import type { CableType, Catalog, Device, DeviceCategoryDef, Diagram, DeviceTemplate, Manufacturer, PortType } from './types'
export function allPortTypes(catalog: Catalog): PortType[] { export function allPortTypes(catalog: Catalog): PortType[] {
return catalog.portTypes return catalog.portTypes
@@ -35,6 +35,22 @@ export function deviceCategoryName(catalog: Catalog, categoryId: string): string
return getDeviceCategory(catalog, categoryId)?.name ?? categoryId return getDeviceCategory(catalog, categoryId)?.name ?? categoryId
} }
export function allManufacturers(catalog: Catalog): Manufacturer[] {
return catalog.manufacturers
}
export function getManufacturer(catalog: Catalog, manufacturerId: string): Manufacturer | undefined {
return allManufacturers(catalog).find((m) => m.id === manufacturerId)
}
/** Friendly name for a manufacturer id — unlike `deviceCategoryName`,
* `manufacturerId` is optional (not every device has a known manufacturer),
* so this returns undefined rather than a fallback string when it's unset
* or, for older data, references a manufacturer that no longer exists. */
export function manufacturerName(catalog: Catalog, manufacturerId: string | undefined): string | undefined {
return manufacturerId ? getManufacturer(catalog, manufacturerId)?.name : undefined
}
export function getPortType(catalog: Catalog, portTypeId: string): PortType | undefined { export function getPortType(catalog: Catalog, portTypeId: string): PortType | undefined {
return allPortTypes(catalog).find((pt) => pt.id === portTypeId) return allPortTypes(catalog).find((pt) => pt.id === portTypeId)
} }
@@ -43,6 +59,14 @@ export function getCableType(catalog: Catalog, cableTypeId: string): CableType |
return allCableTypes(catalog).find((ct) => ct.id === cableTypeId) return allCableTypes(catalog).find((ct) => ct.id === cableTypeId)
} }
/** Best-effort friendly label for a raw family string: the name of whichever
* port type owns that family, or the raw string itself if none matches
* (e.g. a family that only ever existed as hand-typed text, from before
* FamilySelect started sourcing new families from an existing port type). */
export function familyLabel(portTypes: PortType[], family: string): string {
return portTypes.find((pt) => pt.family === family)?.name ?? family
}
/** The set of connector families a port type will mate with: its own family /** The set of connector families a port type will mate with: its own family
* plus anything it explicitly lists as also-compatible. Shared with * plus anything it explicitly lists as also-compatible. Shared with
* `compatibility.ts` so "does A connect to B" and "which cable fits" agree. */ * `compatibility.ts` so "does A connect to B" and "which cable fits" agree. */
@@ -145,7 +169,7 @@ export function createDeviceFromTemplate(template: DeviceTemplate, position: { x
name: template.name, name: template.name,
templateId: template.id, templateId: template.id,
category: template.category, category: template.category,
manufacturer: template.manufacturer, manufacturerId: template.manufacturerId,
model: template.model, model: template.model,
ports: template.ports.map((port) => ({ ...port, id: uuid() })), ports: template.ports.map((port) => ({ ...port, id: uuid() })),
cost: template.cost, cost: template.cost,
+25 -2
View File
@@ -28,6 +28,16 @@ export interface DeviceCategoryDef {
custom?: boolean custom?: boolean
} }
/** A device manufacturer/brand, normalized the same way as DeviceCategoryDef
* (organized-ideas.md §3) rather than free text on each device — gives the
* Admin review queue's duplicate-detection nudge a reliable signal
* (matching manufacturer + similar model) that name-matching alone can't. */
export interface Manufacturer {
id: string
name: string
custom?: boolean
}
/** /**
* A kind of physical connector (HDMI, XLR, Cat6/RJ45, ...). Compatibility * A kind of physical connector (HDMI, XLR, Cat6/RJ45, ...). Compatibility
* between two ports is decided by comparing `family` (plus optional * between two ports is decided by comparing `family` (plus optional
@@ -54,6 +64,14 @@ export interface Port {
name: string name: string
direction: PortDirection direction: PortDirection
portTypeId: string portTypeId: string
/** This port already has a captive/permanently-attached cable (a
* keyboard's USB cable, a budget AV receiver's power cord, ...) — a
* property of the physical port, not of any one wiring choice, so it
* lives here rather than on Connection. Any connection touching a port
* with this set doesn't need a separate purchased cable: the BOM omits
* it entirely, though the canvas still draws the link (styled
* differently) since the wiring topology is still real. */
builtInCable?: boolean
} }
/** A reusable, non-instantiated device definition (shown in the palette). */ /** A reusable, non-instantiated device definition (shown in the palette). */
@@ -61,7 +79,9 @@ export interface DeviceTemplate {
id: string id: string
name: string name: string
category: DeviceCategory category: DeviceCategory
manufacturer?: string /** References a Manufacturer catalog entry — optional since not every
* device has a known/chosen manufacturer (e.g. DIY or generic gear). */
manufacturerId?: string
model?: string model?: string
ports: Port[] ports: Port[]
/** Suggested default cost, copied onto each placed Device at creation time /** Suggested default cost, copied onto each placed Device at creation time
@@ -82,7 +102,9 @@ export interface Device {
name: string name: string
templateId?: string templateId?: string
category: DeviceCategory category: DeviceCategory
manufacturer?: string /** References a Manufacturer catalog entry, same as the template's —
* independently editable per instance afterward, like category/model. */
manufacturerId?: string
model?: string model?: string
notes?: string notes?: string
ports: Port[] ports: Port[]
@@ -156,5 +178,6 @@ export interface Catalog {
portTypes: PortType[] portTypes: PortType[]
cableTypes: CableType[] cableTypes: CableType[]
deviceCategories: DeviceCategoryDef[] deviceCategories: DeviceCategoryDef[]
manufacturers: Manufacturer[]
deviceTemplates: DeviceTemplate[] deviceTemplates: DeviceTemplate[]
} }
+153 -2
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { CableType, Catalog, DeviceTemplate, PortType } from '../domain/types' import type { CableType, Catalog, DeviceTemplate, PortType } from '../domain/types'
import type { CatalogRepository } from '../data/CatalogRepository' import type { CatalogRepository } from '../data/CatalogRepository'
import { cableTypeToRow, deviceTemplateToRow, portTypeToRow } from '../data/catalogRowMapping' import { cableTypeToRow, deviceCategoryToRow, deviceTemplateToRow, manufacturerToRow, portTypeToRow } from '../data/catalogRowMapping'
import { SupabaseCatalogRepository } from '../data/SupabaseCatalogRepository' import { SupabaseCatalogRepository } from '../data/SupabaseCatalogRepository'
import type { CatalogEntityType } from '../data/SubmissionRepository' import type { CatalogEntityType } from '../data/SubmissionRepository'
import { useSubmissionStore } from './submissionStore' import { useSubmissionStore } from './submissionStore'
@@ -29,7 +29,7 @@ function saveHiddenPublicIds(ids: Set<string>): void {
} }
} }
const emptyCatalog: Catalog = { portTypes: [], cableTypes: [], deviceCategories: [], deviceTemplates: [] } const emptyCatalog: Catalog = { portTypes: [], cableTypes: [], deviceCategories: [], manufacturers: [], deviceTemplates: [] }
interface CatalogStoreState { interface CatalogStoreState {
catalog: Catalog catalog: Catalog
@@ -39,12 +39,23 @@ interface CatalogStoreState {
loadCatalog: () => Promise<void> loadCatalog: () => Promise<void>
addCustomDeviceCategory: (name: string) => Promise<string> addCustomDeviceCategory: (name: string) => Promise<string>
updateCustomDeviceCategory: (id: string, name: string) => Promise<void>
removeCustomDeviceCategory: (id: string) => Promise<void>
addCustomManufacturer: (name: string) => Promise<string>
updateCustomManufacturer: (id: string, name: string) => Promise<void>
removeCustomManufacturer: (id: string) => Promise<void>
addCustomPortType: (portType: Omit<PortType, 'id' | 'custom'>) => Promise<void> addCustomPortType: (portType: Omit<PortType, 'id' | 'custom'>) => Promise<void>
updateCustomPortType: (id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>) => Promise<void> updateCustomPortType: (id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>) => Promise<void>
/** Throws (like removeCustomDeviceCategory) if the delete is rejected —
* most commonly a foreign-key violation because a device template's port
* still references it. */
removeCustomPortType: (id: string) => Promise<void>
addCustomCableType: (cableType: Omit<CableType, 'id' | 'custom'>) => Promise<void> addCustomCableType: (cableType: Omit<CableType, 'id' | 'custom'>) => Promise<void>
updateCustomCableType: (id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>) => Promise<void> updateCustomCableType: (id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>) => Promise<void>
removeCustomCableType: (id: string) => Promise<void>
addCustomDeviceTemplate: (template: Omit<DeviceTemplate, 'id' | 'custom'>) => Promise<void> addCustomDeviceTemplate: (template: Omit<DeviceTemplate, 'id' | 'custom'>) => Promise<void>
updateCustomDeviceTemplate: (id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>) => Promise<void> updateCustomDeviceTemplate: (id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>) => Promise<void>
@@ -53,15 +64,35 @@ interface CatalogStoreState {
hidePublicDeviceTemplate: (id: string) => void hidePublicDeviceTemplate: (id: string) => void
restorePublicDeviceTemplate: (id: string) => void restorePublicDeviceTemplate: (id: string) => void
/** Admin/Super-Admin direct-to-public creation — skips the submission/
* review workflow, for authoring the shared library yourself (see
* CatalogRepository.adminAddDeviceCategory's own comment). */
adminAddDeviceCategory: (name: string) => Promise<string>
adminAddManufacturer: (name: string) => Promise<string>
adminAddPortType: (portType: Omit<PortType, 'id' | 'custom'>) => Promise<void>
adminAddCableType: (cableType: Omit<CableType, 'id' | 'custom'>) => Promise<void>
adminAddDeviceTemplate: (template: Omit<DeviceTemplate, 'id' | 'custom'>) => Promise<void>
/** Admin/Super-Admin direct edit of an already-public entry, per /** Admin/Super-Admin direct edit of an already-public entry, per
* organized-ideas.md §6 — bypasses the submission/review workflow * organized-ideas.md §6 — bypasses the submission/review workflow
* entirely (RLS's is_admin() clause is what actually permits it). */ * entirely (RLS's is_admin() clause is what actually permits it). */
adminUpdatePortType: (id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>) => Promise<void> adminUpdatePortType: (id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>) => Promise<void>
adminUpdateCableType: (id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>) => Promise<void> adminUpdateCableType: (id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>) => Promise<void>
adminUpdateDeviceTemplate: (id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>) => Promise<void> adminUpdateDeviceTemplate: (id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>) => Promise<void>
adminUpdateDeviceCategory: (id: string, name: string) => Promise<void>
adminUpdateManufacturer: (id: string, name: string) => Promise<void>
/** Unpublishes (is_public -> false) rather than deletes — see /** Unpublishes (is_public -> false) rather than deletes — see
* CatalogRepository.adminUnpublish. */ * CatalogRepository.adminUnpublish. */
adminUnpublish: (entityType: CatalogEntityType, id: string) => Promise<void> adminUnpublish: (entityType: CatalogEntityType, id: string) => Promise<void>
/** Permanent delete of a built-in entry — see
* CatalogRepository.adminRemoveDeviceCategory's own comment for the
* zero-usage contract the caller is expected to have already checked. */
adminRemoveDeviceCategory: (id: string) => Promise<void>
adminRemoveManufacturer: (id: string) => Promise<void>
adminRemovePortType: (id: string) => Promise<void>
adminRemoveCableType: (id: string) => Promise<void>
adminRemoveDeviceTemplate: (id: string) => Promise<void>
} }
export const useCatalogStore = create<CatalogStoreState>((set, get) => ({ export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
@@ -80,11 +111,70 @@ export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
return category.id return category.id
}, },
updateCustomDeviceCategory: async (id, name) => {
await repository.updateDeviceCategory(id, name)
set((state) => ({
catalog: {
...state.catalog,
deviceCategories: state.catalog.deviceCategories.map((c) => (c.id === id ? { ...c, name } : c)),
},
}))
useSubmissionStore.getState().syncProposedData('device_category', id, deviceCategoryToRow(name))
},
removeCustomDeviceCategory: async (id) => {
await repository.removeDeviceCategory(id)
set((state) => ({
catalog: { ...state.catalog, deviceCategories: state.catalog.deviceCategories.filter((c) => c.id !== id) },
}))
},
adminAddDeviceCategory: async (name) => {
const category = await repository.adminAddDeviceCategory(name)
set((state) => ({ catalog: { ...state.catalog, deviceCategories: [...state.catalog.deviceCategories, category] } }))
return category.id
},
addCustomManufacturer: async (name) => {
const manufacturer = await repository.addManufacturer(name)
set((state) => ({ catalog: { ...state.catalog, manufacturers: [...state.catalog.manufacturers, manufacturer] } }))
return manufacturer.id
},
updateCustomManufacturer: async (id, name) => {
await repository.updateManufacturer(id, name)
set((state) => ({
catalog: {
...state.catalog,
manufacturers: state.catalog.manufacturers.map((m) => (m.id === id ? { ...m, name } : m)),
},
}))
useSubmissionStore.getState().syncProposedData('manufacturer', id, manufacturerToRow(name))
},
removeCustomManufacturer: async (id) => {
await repository.removeManufacturer(id)
set((state) => ({
catalog: { ...state.catalog, manufacturers: state.catalog.manufacturers.filter((m) => m.id !== id) },
}))
},
adminAddManufacturer: async (name) => {
const manufacturer = await repository.adminAddManufacturer(name)
set((state) => ({ catalog: { ...state.catalog, manufacturers: [...state.catalog.manufacturers, manufacturer] } }))
return manufacturer.id
},
addCustomPortType: async (portType) => { addCustomPortType: async (portType) => {
const created = await repository.addPortType(portType) const created = await repository.addPortType(portType)
set((state) => ({ catalog: { ...state.catalog, portTypes: [...state.catalog.portTypes, created] } })) set((state) => ({ catalog: { ...state.catalog, portTypes: [...state.catalog.portTypes, created] } }))
}, },
adminAddPortType: async (portType) => {
const created = await repository.adminAddPortType(portType)
set((state) => ({ catalog: { ...state.catalog, portTypes: [...state.catalog.portTypes, created] } }))
},
updateCustomPortType: async (id, patch) => { updateCustomPortType: async (id, patch) => {
await repository.updatePortType(id, patch) await repository.updatePortType(id, patch)
set((state) => ({ set((state) => ({
@@ -97,11 +187,21 @@ export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
if (updated) useSubmissionStore.getState().syncProposedData('port_type', id, portTypeToRow(updated)) if (updated) useSubmissionStore.getState().syncProposedData('port_type', id, portTypeToRow(updated))
}, },
removeCustomPortType: async (id) => {
await repository.removePortType(id)
set((state) => ({ catalog: { ...state.catalog, portTypes: state.catalog.portTypes.filter((pt) => pt.id !== id) } }))
},
addCustomCableType: async (cableType) => { addCustomCableType: async (cableType) => {
const created = await repository.addCableType(cableType) const created = await repository.addCableType(cableType)
set((state) => ({ catalog: { ...state.catalog, cableTypes: [...state.catalog.cableTypes, created] } })) set((state) => ({ catalog: { ...state.catalog, cableTypes: [...state.catalog.cableTypes, created] } }))
}, },
adminAddCableType: async (cableType) => {
const created = await repository.adminAddCableType(cableType)
set((state) => ({ catalog: { ...state.catalog, cableTypes: [...state.catalog.cableTypes, created] } }))
},
updateCustomCableType: async (id, patch) => { updateCustomCableType: async (id, patch) => {
await repository.updateCableType(id, patch) await repository.updateCableType(id, patch)
set((state) => ({ set((state) => ({
@@ -114,11 +214,21 @@ export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
if (updated) useSubmissionStore.getState().syncProposedData('cable_type', id, cableTypeToRow(updated)) if (updated) useSubmissionStore.getState().syncProposedData('cable_type', id, cableTypeToRow(updated))
}, },
removeCustomCableType: async (id) => {
await repository.removeCableType(id)
set((state) => ({ catalog: { ...state.catalog, cableTypes: state.catalog.cableTypes.filter((ct) => ct.id !== id) } }))
},
addCustomDeviceTemplate: async (template) => { addCustomDeviceTemplate: async (template) => {
const created = await repository.addDeviceTemplate(template) const created = await repository.addDeviceTemplate(template)
set((state) => ({ catalog: { ...state.catalog, deviceTemplates: [...state.catalog.deviceTemplates, created] } })) set((state) => ({ catalog: { ...state.catalog, deviceTemplates: [...state.catalog.deviceTemplates, created] } }))
}, },
adminAddDeviceTemplate: async (template) => {
const created = await repository.adminAddDeviceTemplate(template)
set((state) => ({ catalog: { ...state.catalog, deviceTemplates: [...state.catalog.deviceTemplates, created] } }))
},
updateCustomDeviceTemplate: async (id, patch) => { updateCustomDeviceTemplate: async (id, patch) => {
await repository.updateDeviceTemplate(id, patch) await repository.updateDeviceTemplate(id, patch)
set((state) => ({ set((state) => ({
@@ -174,8 +284,49 @@ export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
await get().loadCatalog() await get().loadCatalog()
}, },
adminUpdateDeviceCategory: async (id, name) => {
await repository.adminUpdateDeviceCategory(id, name)
await get().loadCatalog()
},
adminUpdateManufacturer: async (id, name) => {
await repository.adminUpdateManufacturer(id, name)
await get().loadCatalog()
},
adminUnpublish: async (entityType, id) => { adminUnpublish: async (entityType, id) => {
await repository.adminUnpublish(entityType, id) await repository.adminUnpublish(entityType, id)
await get().loadCatalog() await get().loadCatalog()
}, },
adminRemoveDeviceCategory: async (id) => {
await repository.adminRemoveDeviceCategory(id)
set((state) => ({
catalog: { ...state.catalog, deviceCategories: state.catalog.deviceCategories.filter((c) => c.id !== id) },
}))
},
adminRemoveManufacturer: async (id) => {
await repository.adminRemoveManufacturer(id)
set((state) => ({
catalog: { ...state.catalog, manufacturers: state.catalog.manufacturers.filter((m) => m.id !== id) },
}))
},
adminRemovePortType: async (id) => {
await repository.adminRemovePortType(id)
set((state) => ({ catalog: { ...state.catalog, portTypes: state.catalog.portTypes.filter((pt) => pt.id !== id) } }))
},
adminRemoveCableType: async (id) => {
await repository.adminRemoveCableType(id)
set((state) => ({ catalog: { ...state.catalog, cableTypes: state.catalog.cableTypes.filter((ct) => ct.id !== id) } }))
},
adminRemoveDeviceTemplate: async (id) => {
await repository.adminRemoveDeviceTemplate(id)
set((state) => ({
catalog: { ...state.catalog, deviceTemplates: state.catalog.deviceTemplates.filter((t) => t.id !== id) },
}))
},
})) }))
@@ -0,0 +1,30 @@
-- Wires up the normalized `manufacturers` catalog (organized-ideas.md §3:
-- "manufacturer becomes a normalized, submittable catalog — the same
-- public/user-defined pattern already used for device categories").
--
-- The table itself, its RLS policies, and device_templates.manufacturer_id
-- have all existed unused since the initial schema / the manufacturer_text
-- migration — this is the pass that actually populates and reads them.
-- Scope matches what device_category has today: create-your-own-private-one
-- only, no submission/admin-edit UI yet (that's a separate, already-tracked
-- follow-up for both entities together).
--
-- Any existing free-text manufacturer values (there's realistically at most
-- a handful of dev-only rows at this point, nothing shipped) get backfilled
-- into a manufacturers row each, grouped by (name, owner, is_public) so two
-- different users' private "Fake" manufacturers don't collide into one row.
insert into public.manufacturers (name, is_public, owner_id)
select distinct dt.manufacturer, dt.is_public, dt.owner_id
from public.device_templates dt
where dt.manufacturer is not null;
update public.device_templates dt
set manufacturer_id = m.id
from public.manufacturers m
where dt.manufacturer is not null
and m.name = dt.manufacturer
and m.is_public = dt.is_public
and m.owner_id is not distinct from dt.owner_id;
alter table public.device_templates drop column manufacturer;
@@ -0,0 +1,61 @@
-- Rounds device_category and manufacturer out to full parity with the
-- other catalog entity types: submission/review workflow (already halfway
-- there — catalog_submissions.entity_type has allowed both since the
-- initial schema, and the app-side plumbing for device_category already
-- exists in the review queue and My Submissions), plus direct rename of
-- your own private entry / an already-public one by an Admin.
--
-- No new tables or RLS policies — device_categories/manufacturers already
-- have full is_public/owner_id CRUD policies from the initial schema
-- (device_categories is explicitly the "representative case" the RLS test
-- suite already covers for this shape), just never exercised by app code
-- for anything beyond insert. This migration only extends the one function
-- that didn't already know about manufacturers.
create or replace function public.catalog_entity_usage_impact(p_entity_type text, p_entity_id text)
returns table(diagram_count integer, sample jsonb)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not public.is_admin() then
raise exception 'insufficient_privilege' using errcode = '42501';
end if;
return query
select count(*)::int, coalesce(jsonb_agg(jsonb_build_object('id', s.id, 'name', s.name, 'ownerUsername', s.owner_username) order by s.rn) filter (where s.rn <= 5), '[]'::jsonb)
from (
select d.id, d.name, p.username as owner_username,
row_number() over (order by d.updated_at desc) as rn
from public.diagrams d
join public.profiles p on p.id = d.owner_id
where case p_entity_type
when 'device_template' then exists (
select 1 from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev
where dev ->> 'templateId' = p_entity_id
)
when 'device_category' then exists (
select 1 from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev
where dev ->> 'category' = p_entity_id
)
when 'manufacturer' then exists (
select 1 from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev
where dev ->> 'manufacturerId' = p_entity_id
)
when 'port_type' then exists (
select 1
from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev,
jsonb_array_elements(coalesce(dev -> 'ports', '[]'::jsonb)) port
where port ->> 'portTypeId' = p_entity_id
)
when 'cable_type' then exists (
select 1 from jsonb_array_elements(coalesce(d.data -> 'connections', '[]'::jsonb)) conn
where conn ->> 'cableTypeId' = p_entity_id
)
else false
end
) s;
end;
$$;
@@ -0,0 +1,76 @@
-- Lets a regular user check the usage-impact of their OWN still-private
-- device category or manufacturer, not just Admins — needed so the app can
-- warn "used in N diagrams, can't delete" before a destructive delete of
-- your own catalog entry, the same way it already warns an Admin before an
-- unpublish. Scoped to just these two entity types and only entities you
-- privately own (is_public = false, owner_id = you): a still-private
-- category/manufacturer could only ever have been referenced by things you
-- yourself created (nobody else's picker would ever have offered it), so
-- this can't be used to snoop on another user's diagrams the way opening
-- this up generally would.
create or replace function public.catalog_entity_usage_impact(p_entity_type text, p_entity_id text)
returns table(diagram_count integer, sample jsonb)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not (
public.is_admin()
or (
p_entity_type = 'device_category'
and exists (
select 1 from public.device_categories
where id = p_entity_id and owner_id = auth.uid() and not is_public
)
)
or (
p_entity_type = 'manufacturer'
and exists (
-- manufacturers.id is still uuid (never migrated to text like the
-- other catalog tables — see 20260908033553_catalog_text_ids.sql's
-- own comment on why), hence the cast.
select 1 from public.manufacturers
where id::text = p_entity_id and owner_id = auth.uid() and not is_public
)
)
) then
raise exception 'insufficient_privilege' using errcode = '42501';
end if;
return query
select count(*)::int, coalesce(jsonb_agg(jsonb_build_object('id', s.id, 'name', s.name, 'ownerUsername', s.owner_username) order by s.rn) filter (where s.rn <= 5), '[]'::jsonb)
from (
select d.id, d.name, p.username as owner_username,
row_number() over (order by d.updated_at desc) as rn
from public.diagrams d
join public.profiles p on p.id = d.owner_id
where case p_entity_type
when 'device_template' then exists (
select 1 from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev
where dev ->> 'templateId' = p_entity_id
)
when 'device_category' then exists (
select 1 from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev
where dev ->> 'category' = p_entity_id
)
when 'manufacturer' then exists (
select 1 from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev
where dev ->> 'manufacturerId' = p_entity_id
)
when 'port_type' then exists (
select 1
from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev,
jsonb_array_elements(coalesce(dev -> 'ports', '[]'::jsonb)) port
where port ->> 'portTypeId' = p_entity_id
)
when 'cable_type' then exists (
select 1 from jsonb_array_elements(coalesce(d.data -> 'connections', '[]'::jsonb)) conn
where conn ->> 'cableTypeId' = p_entity_id
)
else false
end
) s;
end;
$$;
@@ -0,0 +1,9 @@
-- Devices with a captive/permanently-attached cable (a keyboard's USB
-- cable, a budget AV receiver's power cord, some passive speakers' fixed
-- speaker wire, ...) — a property of the physical port, not of any one
-- wiring choice, so it lives on device_template_ports rather than on
-- connections (which aren't even a relational table — see diagrams' JSONB
-- `data` for those; nothing to migrate there, the domain type change alone
-- covers newly-placed devices, and this column is what the palette/library
-- side needs).
alter table public.device_template_ports add column built_in_cable boolean not null default false;