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:
@@ -11,13 +11,20 @@ export default function CategorySelect({
|
||||
value,
|
||||
onChange,
|
||||
className = 'mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm',
|
||||
publishPublic = false,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (categoryId: string) => void
|
||||
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 addCustomDeviceCategory = useCatalogStore((s) => s.addCustomDeviceCategory)
|
||||
const adminAddDeviceCategory = useCatalogStore((s) => s.adminAddDeviceCategory)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [draftName, setDraftName] = useState('')
|
||||
|
||||
@@ -29,7 +36,7 @@ export default function CategorySelect({
|
||||
const commitNewCategory = async () => {
|
||||
const trimmed = draftName.trim()
|
||||
if (trimmed) {
|
||||
onChange(await addCustomDeviceCategory(trimmed))
|
||||
onChange(await (publishPublic ? adminAddDeviceCategory(trimmed) : addCustomDeviceCategory(trimmed)))
|
||||
}
|
||||
setCreating(false)
|
||||
setDraftName('')
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { PortDirection } from '../../domain/types'
|
||||
import { useCatalogStore } from '../../state/catalogStore'
|
||||
import { useDiagramStore } from '../../state/diagramStore'
|
||||
import CategorySelect from '../common/CategorySelect'
|
||||
import ManufacturerSelect from '../common/ManufacturerSelect'
|
||||
|
||||
export default function DeviceInspector({ deviceId }: { deviceId: string }) {
|
||||
const diagram = useDiagramStore((s) => s.diagram)
|
||||
@@ -47,10 +48,9 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
|
||||
</label>
|
||||
<label className="text-xs font-medium text-slate-600">
|
||||
Manufacturer
|
||||
<input
|
||||
value={device.manufacturer ?? ''}
|
||||
onChange={(e) => updateDevice(device.id, { manufacturer: e.target.value })}
|
||||
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
||||
<ManufacturerSelect
|
||||
value={device.manufacturerId}
|
||||
onChange={(manufacturerId) => updateDevice(device.id, { manufacturerId })}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs font-medium text-slate-600">
|
||||
@@ -95,7 +95,7 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
|
||||
{device.ports.map((port) => {
|
||||
const inUse = connectionCountForPort(port.id) > 0
|
||||
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
|
||||
ref={(el) => {
|
||||
if (el && focusPortId === port.id) {
|
||||
@@ -129,23 +129,38 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setFocusPortId(clonePort(device.id, port.id))}
|
||||
tabIndex={-1}
|
||||
title="Duplicate this port"
|
||||
className="shrink-0 rounded px-1 text-slate-400 hover:bg-indigo-50 hover:text-indigo-600"
|
||||
>
|
||||
⧉
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removePort(device.id, port.id)}
|
||||
disabled={inUse}
|
||||
tabIndex={-1}
|
||||
title={inUse ? 'Disconnect its cable first' : 'Remove port'}
|
||||
className="shrink-0 rounded px-1 text-slate-400 hover:bg-red-50 hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/* 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
|
||||
onClick={() => setFocusPortId(clonePort(device.id, port.id))}
|
||||
tabIndex={-1}
|
||||
title="Duplicate this port"
|
||||
className="shrink-0 rounded px-1 text-slate-400 hover:bg-indigo-50 hover:text-indigo-600"
|
||||
>
|
||||
⧉
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removePort(device.id, port.id)}
|
||||
disabled={inUse}
|
||||
tabIndex={-1}
|
||||
title={inUse ? 'Disconnect its cable first' : 'Remove port'}
|
||||
className="shrink-0 rounded px-1 text-slate-400 hover:bg-red-50 hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { cableTypeToRow } from '../../data/catalogRowMapping'
|
||||
import { useAdminReviewStore } from '../../state/adminReviewStore'
|
||||
@@ -10,23 +10,19 @@ import { resolveCableTypeFields, type CableTypeFormFields } from './cableTypeFor
|
||||
|
||||
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({
|
||||
initial,
|
||||
portTypes,
|
||||
showPublishToggle = false,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
initial?: CableType
|
||||
portTypes: PortType[]
|
||||
/** See PortTypeForm's own doc — same "new + Admin" -only publish toggle. */
|
||||
showPublishToggle?: boolean
|
||||
onCancel: () => void
|
||||
onSave: (fields: CableTypeFormFields) => void
|
||||
onSave: (fields: CableTypeFormFields, publishPublic: boolean) => void
|
||||
}) {
|
||||
const initialEndA = initial ? portTypes.find((pt) => pt.family === 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 [unit, setUnit] = useState<CableType['unit']>(initial?.unit ?? 'ft')
|
||||
const [costPerUnit, setCostPerUnit] = useState(initial?.costPerUnit?.toString() ?? '')
|
||||
const [publishPublic, setPublishPublic] = useState(false)
|
||||
|
||||
const isAdapter = endAPortTypeId !== endBPortTypeId
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim() || !endAPortTypeId || !endBPortTypeId) return
|
||||
onSave({
|
||||
name: name.trim(),
|
||||
endAPortTypeId,
|
||||
endBPortTypeId,
|
||||
unit,
|
||||
costPerUnit: costPerUnit.trim() === '' ? undefined : Number(costPerUnit),
|
||||
})
|
||||
onSave(
|
||||
{
|
||||
name: name.trim(),
|
||||
endAPortTypeId,
|
||||
endBPortTypeId,
|
||||
unit,
|
||||
costPerUnit: costPerUnit.trim() === '' ? undefined : Number(costPerUnit),
|
||||
},
|
||||
publishPublic,
|
||||
)
|
||||
}
|
||||
|
||||
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"
|
||||
/>
|
||||
</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">
|
||||
<button onClick={onCancel} className="text-[11px] text-slate-500">
|
||||
Cancel
|
||||
@@ -131,7 +137,10 @@ export default function CableTypesPanel() {
|
||||
const catalog = useCatalogStore((s) => s.catalog)
|
||||
const addCustomCableType = useCatalogStore((s) => s.addCustomCableType)
|
||||
const updateCustomCableType = useCatalogStore((s) => s.updateCustomCableType)
|
||||
const removeCustomCableType = useCatalogStore((s) => s.removeCustomCableType)
|
||||
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 mySubmissions = useSubmissionStore((s) => s.mySubmissions)
|
||||
const submitForReview = useSubmissionStore((s) => s.submitForReview)
|
||||
@@ -163,15 +172,16 @@ export default function CableTypesPanel() {
|
||||
)
|
||||
|
||||
const describeEnds = (ct: CableType) => {
|
||||
const a = familyLabel(ct.family, portTypes)
|
||||
const b = familyLabel(ct.family2 ?? ct.family, portTypes)
|
||||
const a = familyLabel(portTypes, ct.family)
|
||||
const b = familyLabel(portTypes, ct.family2 ?? ct.family)
|
||||
return a === b ? a : `${a} ↔ ${b}`
|
||||
}
|
||||
|
||||
const handleCreate = (fields: CableTypeFormFields) => {
|
||||
const handleCreate = (fields: CableTypeFormFields, publishPublic: boolean) => {
|
||||
const resolved = resolveCableTypeFields(fields, portTypes)
|
||||
if (!resolved) return
|
||||
addCustomCableType(resolved)
|
||||
if (isAdmin && publishPublic) adminAddCableType(resolved)
|
||||
else addCustomCableType(resolved)
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
@@ -211,6 +221,29 @@ export default function CableTypesPanel() {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
@@ -226,7 +259,12 @@ export default function CableTypesPanel() {
|
||||
</div>
|
||||
{creating && (
|
||||
<div className="mb-1.5">
|
||||
<CableTypeForm portTypes={portTypes} onCancel={() => setCreating(false)} onSave={handleCreate} />
|
||||
<CableTypeForm
|
||||
portTypes={portTypes}
|
||||
showPublishToggle={isAdmin}
|
||||
onCancel={() => setCreating(false)}
|
||||
onSave={handleCreate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{customTypes.length === 0 && !creating ? (
|
||||
@@ -276,6 +314,12 @@ export default function CableTypesPanel() {
|
||||
>
|
||||
Edit
|
||||
</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>
|
||||
),
|
||||
@@ -333,6 +377,13 @@ export default function CableTypesPanel() {
|
||||
>
|
||||
Unpublish
|
||||
</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>
|
||||
) : 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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
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 { useAdminReviewStore } from '../../state/adminReviewStore'
|
||||
import { useAuthStore } from '../../state/authStore'
|
||||
@@ -9,6 +9,8 @@ import { useDiagramStore } from '../../state/diagramStore'
|
||||
import { useSubmissionStore } from '../../state/submissionStore'
|
||||
import { TEMPLATE_DRAG_MIME } from '../canvas/FlowCanvas'
|
||||
import Chevron from '../common/Chevron'
|
||||
import DropdownMenu, { DropdownMenuItem } from '../common/DropdownMenu'
|
||||
import CategoryLibraryModal from './CategoryLibraryModal'
|
||||
import ConnectorLibraryModal from './ConnectorLibraryModal'
|
||||
import DeviceTemplateEditor from './DeviceTemplateEditor'
|
||||
|
||||
@@ -18,6 +20,39 @@ type EditorTarget =
|
||||
| { mode: 'suggestEdit'; 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() {
|
||||
const diagram = useDiagramStore((s) => s.diagram)
|
||||
const access = useDiagramStore((s) => s.access)
|
||||
@@ -29,6 +64,7 @@ export default function DevicePalette() {
|
||||
const hidePublicDeviceTemplate = useCatalogStore((s) => s.hidePublicDeviceTemplate)
|
||||
const restorePublicDeviceTemplate = useCatalogStore((s) => s.restorePublicDeviceTemplate)
|
||||
const adminUnpublish = useCatalogStore((s) => s.adminUnpublish)
|
||||
const adminRemoveDeviceTemplate = useCatalogStore((s) => s.adminRemoveDeviceTemplate)
|
||||
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
|
||||
const submitForReview = useSubmissionStore((s) => s.submitForReview)
|
||||
const getUsageImpact = useAdminReviewStore((s) => s.getUsageImpact)
|
||||
@@ -36,40 +72,77 @@ export default function DevicePalette() {
|
||||
const isAdmin = role === 'admin' || role === 'super_admin'
|
||||
const [editorTarget, setEditorTarget] = useState<EditorTarget | null>(null)
|
||||
const [portTypesOpen, setPortTypesOpen] = useState(false)
|
||||
const [categoryLibraryOpen, setCategoryLibraryOpen] = 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
|
||||
// library with many devices from turning into one long scroll).
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||
// library with many devices from turning into one long scroll). Shared
|
||||
// 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 categories = allDeviceCategories(catalog)
|
||||
const categoryName = (id: string) => categories.find((c) => c.id === id)?.name ?? id
|
||||
const byCategory = new Map<string, DeviceTemplate[]>()
|
||||
for (const template of allDeviceTemplates(catalog, hiddenPublicIds)) {
|
||||
const list = byCategory.get(template.category) ?? []
|
||||
list.push(template)
|
||||
byCategory.set(template.category, list)
|
||||
const groups = useMemo<TopGroup[]>(() => {
|
||||
const templates = allDeviceTemplates(catalog, hiddenPublicIds)
|
||||
const categoryLabel = (id: string) => deviceCategoryName(catalog, id)
|
||||
const manufacturerLabel = (id: string) =>
|
||||
id === NO_MANUFACTURER_KEY ? 'No manufacturer' : (manufacturerName(catalog, id) ?? id)
|
||||
const manufacturerKey = (t: DeviceTemplate) => t.manufacturerId ?? NO_MANUFACTURER_KEY
|
||||
|
||||
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) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const topGroups = [...byTop.entries()].map(([topKey, subMap]) => {
|
||||
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)
|
||||
if (next.has(categoryId)) next.delete(categoryId)
|
||||
else next.add(categoryId)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const expandCategory = (categoryId: string) => {
|
||||
setExpandedCategories((prev) => new Set(prev).add(categoryId))
|
||||
const expandGroup = (key: string) => {
|
||||
setExpandedGroups((prev) => new Set(prev).add(key))
|
||||
}
|
||||
|
||||
const handleDragStart = (event: React.DragEvent, template: DeviceTemplate) => {
|
||||
@@ -124,18 +197,53 @@ export default function DevicePalette() {
|
||||
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 (
|
||||
<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">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Devices</h2>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setPortTypesOpen(true)}
|
||||
title="Manage connector types"
|
||||
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-slate-200"
|
||||
>
|
||||
Connectors
|
||||
</button>
|
||||
<DropdownMenu label="Library">
|
||||
<DropdownMenuItem onClick={() => setPortTypesOpen(true)}>Connectors</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setCategoryLibraryOpen(true)}>Categories & Manufacturers</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
<button
|
||||
onClick={() => setEditorTarget({ mode: 'new' })}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
<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.
|
||||
</p>
|
||||
<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">
|
||||
Drag a device onto the canvas, or click + to drop it in.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{groups.map((group) => {
|
||||
const expanded = expandedCategories.has(group.categoryId)
|
||||
return (
|
||||
<div key={group.categoryId} className="mb-1">
|
||||
<button
|
||||
onClick={() => toggleCategory(group.categoryId)}
|
||||
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">
|
||||
<Chevron expanded={expanded} className="text-slate-400" />
|
||||
{group.categoryName}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">{group.templates.length}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<ul className="mt-1 space-y-1 pl-2">
|
||||
{group.templates.map((template) => (
|
||||
<li key={template.id} className="group">
|
||||
<div
|
||||
draggable={canEdit}
|
||||
onDragStart={(e) => handleDragStart(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">
|
||||
{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={() => 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>
|
||||
)}
|
||||
</div>
|
||||
{searchResults ? (
|
||||
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 (
|
||||
<div key={group.key} className="mb-1">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Chevron expanded={expanded} className="text-slate-400" />
|
||||
{group.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">{group.count}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-1 space-y-2 pl-2">
|
||||
{group.subGroups.map((subGroup) => (
|
||||
<div key={subGroup.key}>
|
||||
{/* Not independently collapsible, unlike the top-level
|
||||
group — expanding the category/manufacturer above is
|
||||
the one click this menu asks for; a second click per
|
||||
sub-group would be one too many for groups that
|
||||
usually hold just a couple of devices. */}
|
||||
<div className="px-1.5 text-[10px] font-medium uppercase tracking-wide text-slate-400">
|
||||
{subGroup.label}
|
||||
</div>
|
||||
<ul className="mt-0.5 space-y-1">
|
||||
{subGroup.templates.map((template) => (
|
||||
<DeviceRow key={template.id} template={template} {...rowProps} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{hiddenDefaults.length > 0 && (
|
||||
<div className="border-t border-slate-200 px-2 py-1.5">
|
||||
@@ -289,10 +367,151 @@ export default function DevicePalette() {
|
||||
submissionMode={editorTarget.mode === 'suggestEdit'}
|
||||
adminMode={editorTarget.mode === 'adminEdit'}
|
||||
onClose={() => setEditorTarget(null)}
|
||||
onSaved={(categoryId) => expandCategory(categoryId)}
|
||||
onSaved={(categoryId, manufacturerId) =>
|
||||
expandGroup(viewMode === 'category' ? categoryId : (manufacturerId ?? NO_MANUFACTURER_KEY))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{portTypesOpen && <ConnectorLibraryModal onClose={() => setPortTypesOpen(false)} />}
|
||||
{categoryLibraryOpen && <CategoryLibraryModal onClose={() => setCategoryLibraryOpen(false)} />}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ import { deviceTemplateToRow } from '../../data/catalogRowMapping'
|
||||
import { PORT_CATEGORIES, PORT_DIRECTIONS } from '../../domain/constants'
|
||||
import { allPortTypes, expandCompatibleFamilyIds, incrementPortName } from '../../domain/diagram'
|
||||
import type { DeviceTemplate, PortDirection } from '../../domain/types'
|
||||
import { useAuthStore } from '../../state/authStore'
|
||||
import { useCatalogStore } from '../../state/catalogStore'
|
||||
import { useSubmissionStore } from '../../state/submissionStore'
|
||||
import CategorySelect from '../common/CategorySelect'
|
||||
import FamilySelect from '../common/FamilySelect'
|
||||
import ManufacturerSelect from '../common/ManufacturerSelect'
|
||||
import Modal from '../common/Modal'
|
||||
|
||||
interface DraftPort {
|
||||
@@ -13,6 +16,7 @@ interface DraftPort {
|
||||
name: string
|
||||
direction: PortDirection
|
||||
portTypeId: string
|
||||
builtInCable?: boolean
|
||||
}
|
||||
|
||||
let draftKeySeq = 0
|
||||
@@ -45,17 +49,27 @@ export default function DeviceTemplateEditor({
|
||||
* `submissionMode`/`adminMode` if somehow more than one is set. */
|
||||
resubmitId?: string
|
||||
onClose: () => void
|
||||
/** Called with the template's category id right before closing, so the
|
||||
* palette can expand that category and reveal what was just created/edited. */
|
||||
onSaved?: (categoryId: string) => void
|
||||
/** Called with the template's category and manufacturer ids right before
|
||||
* closing, so the palette can expand whichever group (category or
|
||||
* 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 addCustomDeviceTemplate = useCatalogStore((s) => s.addCustomDeviceTemplate)
|
||||
const updateCustomDeviceTemplate = useCatalogStore((s) => s.updateCustomDeviceTemplate)
|
||||
const adminUpdateDeviceTemplate = useCatalogStore((s) => s.adminUpdateDeviceTemplate)
|
||||
const adminAddDeviceTemplate = useCatalogStore((s) => s.adminAddDeviceTemplate)
|
||||
const addCustomPortType = useCatalogStore((s) => s.addCustomPortType)
|
||||
const adminAddPortType = useCatalogStore((s) => s.adminAddPortType)
|
||||
const submitForReview = useSubmissionStore((s) => s.submitForReview)
|
||||
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(
|
||||
() => [...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 [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 [cost, setCost] = useState(template?.cost?.toString() ?? '')
|
||||
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 ?? '' },
|
||||
],
|
||||
)
|
||||
@@ -79,6 +99,7 @@ export default function DeviceTemplateEditor({
|
||||
const [newPortTypeCategory, setNewPortTypeCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>('other')
|
||||
const [alsoCompatibleWith, setAlsoCompatibleWith] = useState<Set<string>>(new Set())
|
||||
const [focusKey, setFocusKey] = useState<string | null>(null)
|
||||
const [publishDirectly, setPublishDirectly] = useState(false)
|
||||
|
||||
const toggleAlsoCompatible = (portTypeId: string) => {
|
||||
setAlsoCompatibleWith((prev) => {
|
||||
@@ -121,12 +142,17 @@ export default function DeviceTemplateEditor({
|
||||
const handleCreatePortType = () => {
|
||||
if (!newPortTypeName.trim()) return
|
||||
const compatibleFamilyIds = expandCompatibleFamilyIds([...alsoCompatibleWith], portTypes)
|
||||
addCustomPortType({
|
||||
const fields = {
|
||||
name: newPortTypeName.trim(),
|
||||
category: newPortTypeCategory,
|
||||
family: (newPortTypeFamily.trim() || newPortTypeName.trim()).toLowerCase().replace(/\s+/g, '-'),
|
||||
...(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('')
|
||||
setNewPortTypeFamily('')
|
||||
setAlsoCompatibleWith(new Set())
|
||||
@@ -140,10 +166,16 @@ export default function DeviceTemplateEditor({
|
||||
const fields = {
|
||||
name: name.trim(),
|
||||
category,
|
||||
manufacturer: manufacturer.trim() || undefined,
|
||||
manufacturerId,
|
||||
model: model.trim() || undefined,
|
||||
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) {
|
||||
resubmit(resubmitId, deviceTemplateToRow(fields))
|
||||
@@ -153,10 +185,12 @@ export default function DeviceTemplateEditor({
|
||||
submitForReview('device_template', template.id, deviceTemplateToRow(fields))
|
||||
} else if (template) {
|
||||
updateCustomDeviceTemplate(template.id, fields)
|
||||
} else if (canPublishDirectly && publishDirectly) {
|
||||
adminAddDeviceTemplate(fields)
|
||||
} else {
|
||||
addCustomDeviceTemplate(fields)
|
||||
}
|
||||
onSaved?.(category)
|
||||
onSaved?.(category, manufacturerId)
|
||||
onClose()
|
||||
}
|
||||
|
||||
@@ -189,14 +223,14 @@ export default function DeviceTemplateEditor({
|
||||
</label>
|
||||
<label className="text-xs font-medium text-slate-600">
|
||||
Category
|
||||
<CategorySelect value={category} onChange={setCategory} />
|
||||
<CategorySelect value={category} onChange={setCategory} publishPublic={canPublishDirectly && publishDirectly} />
|
||||
</label>
|
||||
<label className="text-xs font-medium text-slate-600">
|
||||
Manufacturer
|
||||
<input
|
||||
value={manufacturer}
|
||||
onChange={(e) => setManufacturer(e.target.value)}
|
||||
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
||||
<ManufacturerSelect
|
||||
value={manufacturerId}
|
||||
onChange={setManufacturerId}
|
||||
publishPublic={canPublishDirectly && publishDirectly}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs font-medium text-slate-600">
|
||||
@@ -224,11 +258,28 @@ export default function DeviceTemplateEditor({
|
||||
</label>
|
||||
</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>
|
||||
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Ports</h4>
|
||||
<div className="space-y-1.5">
|
||||
{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
|
||||
ref={(el) => {
|
||||
if (el && focusKey === port.key) {
|
||||
@@ -263,22 +314,37 @@ export default function DeviceTemplateEditor({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => clonePortRow(port.key)}
|
||||
tabIndex={-1}
|
||||
className="shrink-0 rounded px-1 text-slate-400 hover:bg-indigo-50 hover:text-indigo-600"
|
||||
title="Duplicate this port"
|
||||
>
|
||||
⧉
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removePortRow(port.key)}
|
||||
tabIndex={-1}
|
||||
className="shrink-0 rounded px-1 text-slate-400 hover:bg-red-50 hover:text-red-500"
|
||||
title="Remove port"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/* 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
|
||||
onClick={() => clonePortRow(port.key)}
|
||||
tabIndex={-1}
|
||||
className="shrink-0 rounded px-1 text-slate-400 hover:bg-indigo-50 hover:text-indigo-600"
|
||||
title="Duplicate this port"
|
||||
>
|
||||
⧉
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removePortRow(port.key)}
|
||||
tabIndex={-1}
|
||||
className="shrink-0 rounded px-1 text-slate-400 hover:bg-red-50 hover:text-red-500"
|
||||
title="Remove port"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{ports.length === 0 && <p className="text-[11px] italic text-slate-400">No ports yet.</p>}
|
||||
@@ -316,18 +382,11 @@ export default function DeviceTemplateEditor({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
value={newPortTypeFamily}
|
||||
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>
|
||||
<FamilySelect value={newPortTypeFamily} onChange={setNewPortTypeFamily} portTypes={portTypes} />
|
||||
<p className="text-[10px] text-slate-500">Leave this as a new family and it'll default to the connector name.</p>
|
||||
<div>
|
||||
<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):
|
||||
</p>
|
||||
<div className="mt-1 max-h-24 overflow-y-auto rounded border border-slate-200 bg-white p-1.5">
|
||||
@@ -376,7 +435,9 @@ export default function DeviceTemplateEditor({
|
||||
? 'Submit for review'
|
||||
: template
|
||||
? 'Save changes'
|
||||
: 'Save device'}
|
||||
: canPublishDirectly && publishDirectly
|
||||
? 'Publish device'
|
||||
: 'Save device'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PORT_CATEGORIES } from '../../domain/constants'
|
||||
import { allPortTypes, expandCompatibleFamilyIds } from '../../domain/diagram'
|
||||
import type { PortType } from '../../domain/types'
|
||||
import { portTypeToRow } from '../../data/catalogRowMapping'
|
||||
import FamilySelect from '../common/FamilySelect'
|
||||
import { useAdminReviewStore } from '../../state/adminReviewStore'
|
||||
import { useAuthStore } from '../../state/authStore'
|
||||
import { useCatalogStore } from '../../state/catalogStore'
|
||||
@@ -11,21 +12,30 @@ import { useSubmissionStore } from '../../state/submissionStore'
|
||||
export function PortTypeForm({
|
||||
portType,
|
||||
allTypes,
|
||||
showPublishToggle = false,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
portType: PortType
|
||||
/** Omitted when defining a brand-new port type — mirrors CableTypeForm's
|
||||
* optional `initial`. */
|
||||
portType?: 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
|
||||
onSave: (fields: Omit<PortType, 'id' | 'custom'>) => void
|
||||
onSave: (fields: Omit<PortType, 'id' | 'custom'>, publishPublic: boolean) => void
|
||||
}) {
|
||||
const [name, setName] = useState(portType.name)
|
||||
const [category, setCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>(portType.category)
|
||||
const [family, setFamily] = useState(portType.family)
|
||||
const [name, setName] = useState(portType?.name ?? '')
|
||||
const [category, setCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>(portType?.category ?? 'other')
|
||||
const [family, setFamily] = useState(portType?.family ?? '')
|
||||
const [alsoCompatibleWith, setAlsoCompatibleWith] = useState<Set<string>>(() => {
|
||||
if (!portType) return new Set()
|
||||
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))
|
||||
})
|
||||
const [publishPublic, setPublishPublic] = useState(false)
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setAlsoCompatibleWith((prev) => {
|
||||
@@ -36,18 +46,28 @@ export function PortTypeForm({
|
||||
})
|
||||
}
|
||||
|
||||
const canSave = name.trim().length > 0
|
||||
|
||||
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(
|
||||
(f) => f !== family.trim(),
|
||||
(f) => f !== resolvedFamily,
|
||||
)
|
||||
onSave(
|
||||
{
|
||||
name: name.trim(),
|
||||
category,
|
||||
family: resolvedFamily,
|
||||
compatibleFamilyIds,
|
||||
maxConnections: portType?.maxConnections,
|
||||
},
|
||||
publishPublic,
|
||||
)
|
||||
onSave({
|
||||
name: name.trim(),
|
||||
category,
|
||||
family: family.trim(),
|
||||
compatibleFamilyIds,
|
||||
maxConnections: portType.maxConnections,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -70,17 +90,13 @@ export function PortTypeForm({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
value={family}
|
||||
onChange={(e) => setFamily(e.target.value)}
|
||||
placeholder="Compatibility family"
|
||||
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs"
|
||||
/>
|
||||
<FamilySelect value={family} onChange={setFamily} portTypes={allTypes} />
|
||||
{!portType && <p className="text-[10px] text-slate-500">Leave this as a new family and it'll default to the connector name.</p>}
|
||||
<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">
|
||||
{allTypes
|
||||
.filter((pt) => pt.id !== portType.id)
|
||||
.filter((pt) => pt.id !== portType?.id)
|
||||
.map((pt) => (
|
||||
<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)} />
|
||||
@@ -89,13 +105,20 @@ export function PortTypeForm({
|
||||
))}
|
||||
</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">
|
||||
<button onClick={onCancel} className="text-[11px] text-slate-500">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -106,14 +129,19 @@ export function PortTypeForm({
|
||||
|
||||
export default function PortTypesPanel() {
|
||||
const catalog = useCatalogStore((s) => s.catalog)
|
||||
const addCustomPortType = useCatalogStore((s) => s.addCustomPortType)
|
||||
const updateCustomPortType = useCatalogStore((s) => s.updateCustomPortType)
|
||||
const removeCustomPortType = useCatalogStore((s) => s.removeCustomPortType)
|
||||
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 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 [creating, setCreating] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [suggestingId, setSuggestingId] = 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
|
||||
}
|
||||
|
||||
const handleCreate = (fields: Omit<PortType, 'id' | 'custom'>, publishPublic: boolean) => {
|
||||
if (isAdmin && publishPublic) adminAddPortType(fields)
|
||||
else addCustomPortType(fields)
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
const handleSubmitForPublic = (pt: PortType) => {
|
||||
submitForReview('port_type', pt.id, portTypeToRow(pt))
|
||||
}
|
||||
@@ -168,15 +202,62 @@ export default function PortTypesPanel() {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Custom ({customTypes.length})
|
||||
</h4>
|
||||
{customTypes.length === 0 ? (
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Custom ({customTypes.length})
|
||||
</h4>
|
||||
{!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">
|
||||
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>
|
||||
) : (
|
||||
<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="truncate text-[10px] text-slate-400">
|
||||
family: {pt.family}
|
||||
{describeCompatibility(pt) ? ` · also fits: ${describeCompatibility(pt)}` : ''}
|
||||
{describeCompatibility(pt) ? ` · can accept: ${describeCompatibility(pt)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
@@ -224,6 +305,12 @@ export default function PortTypesPanel() {
|
||||
>
|
||||
Edit
|
||||
</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>
|
||||
),
|
||||
@@ -265,7 +352,7 @@ export default function PortTypesPanel() {
|
||||
<span className="min-w-0 truncate">
|
||||
{pt.name}
|
||||
{describeCompatibility(pt) && (
|
||||
<span className="text-slate-400"> · also fits: {describeCompatibility(pt)}</span>
|
||||
<span className="text-slate-400"> · can accept: {describeCompatibility(pt)}</span>
|
||||
)}
|
||||
</span>
|
||||
{isAdmin ? (
|
||||
@@ -282,6 +369,13 @@ export default function PortTypesPanel() {
|
||||
>
|
||||
Unpublish
|
||||
</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>
|
||||
) : 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">
|
||||
|
||||
@@ -14,6 +14,7 @@ const ENTITY_TYPE_LABELS: Record<CatalogSubmission['entityType'], string> = {
|
||||
port_type: 'Port type',
|
||||
cable_type: 'Cable type',
|
||||
device_category: 'Category',
|
||||
manufacturer: 'Manufacturer',
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
@@ -62,15 +63,36 @@ function SubmissionReviewCard({
|
||||
return diffRows(currentRow, 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(() => {
|
||||
if (isEditToPublicEntry || submission.entityType !== 'device_template') return []
|
||||
if (isEditToPublicEntry) return []
|
||||
const proposedName = typeof submission.proposedData.name === 'string' ? submission.proposedData.name : ''
|
||||
if (!proposedName) return []
|
||||
const publicTemplates = catalog.deviceTemplates
|
||||
.filter((dt) => !dt.custom && dt.id !== submission.entityId)
|
||||
.map((dt) => ({ id: dt.id, name: dt.name }))
|
||||
return findLikelyDuplicates(proposedName, publicTemplates)
|
||||
}, [isEditToPublicEntry, submission.entityType, submission.entityId, submission.proposedData, catalog.deviceTemplates])
|
||||
const candidates =
|
||||
submission.entityType === 'device_template'
|
||||
? catalog.deviceTemplates.filter((dt) => !dt.custom && dt.id !== submission.entityId)
|
||||
: submission.entityType === 'device_category'
|
||||
? 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 () => {
|
||||
setBusy(true)
|
||||
|
||||
@@ -17,6 +17,7 @@ const ENTITY_TYPE_LABELS: Record<CatalogSubmission['entityType'], string> = {
|
||||
port_type: 'Port type',
|
||||
cable_type: 'Cable type',
|
||||
device_category: 'Category',
|
||||
manufacturer: 'Manufacturer',
|
||||
}
|
||||
|
||||
/** 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 resubmit = useSubmissionStore((s) => s.resubmit)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [categoryDraft, setCategoryDraft] = useState('')
|
||||
const [nameDraft, setNameDraft] = useState('')
|
||||
|
||||
const allPortTypesList = allPortTypes(catalog)
|
||||
|
||||
@@ -80,7 +81,7 @@ export default function MySubmissionsModal({ onClose }: { onClose: () => void })
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div key={submission.id} className="flex items-center gap-1.5 rounded border border-indigo-200 bg-indigo-50/40 p-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={categoryDraft}
|
||||
onChange={(e) => setCategoryDraft(e.target.value)}
|
||||
value={nameDraft}
|
||||
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"
|
||||
/>
|
||||
<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
|
||||
onClick={() => {
|
||||
if (!categoryDraft.trim()) return
|
||||
resubmit(submission.id, { name: categoryDraft.trim() })
|
||||
if (!nameDraft.trim()) return
|
||||
resubmit(submission.id, { name: nameDraft.trim() })
|
||||
setEditingId(null)
|
||||
}}
|
||||
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 { 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
|
||||
* 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)
|
||||
case 'device_category':
|
||||
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)
|
||||
case 'device_category':
|
||||
return deviceCategoryToRow(entity.name)
|
||||
case 'manufacturer':
|
||||
return manufacturerToRow(entity.name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,11 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
category_id: 'Category',
|
||||
family: 'Family',
|
||||
family2: 'Family (end 2)',
|
||||
compatible_family_ids: 'Also compatible with',
|
||||
compatible_family_ids: 'Can accept connections from',
|
||||
max_connections: 'Max connections',
|
||||
unit: 'Unit',
|
||||
cost_per_unit: 'Cost per unit',
|
||||
manufacturer: 'Manufacturer',
|
||||
manufacturer_id: 'Manufacturer',
|
||||
model: 'Model',
|
||||
cost: 'Cost',
|
||||
ports: 'Ports',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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
|
||||
@@ -22,26 +22,75 @@ export interface CatalogRepository {
|
||||
load(): Promise<Catalog>
|
||||
|
||||
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>
|
||||
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>
|
||||
updateCableType(id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>): Promise<void>
|
||||
removeCableType(id: string): Promise<void>
|
||||
|
||||
addDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate>
|
||||
updateDeviceTemplate(id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>): 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
|
||||
* entry — same underlying write as approving a submission, minus the
|
||||
* submission. */
|
||||
adminUpdatePortType(id: string, patch: Partial<Omit<PortType, '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>
|
||||
adminUpdateDeviceCategory(id: string, name: string): Promise<void>
|
||||
adminUpdateManufacturer(id: string, name: string): Promise<void>
|
||||
/** Unpublishes a public entry (is_public -> false) rather than deleting
|
||||
* it — organized-ideas.md §3's "public catalog entries are never hard-
|
||||
* deleted, only hidden/unpublished". Existing diagrams that reference it
|
||||
* by id are unaffected; it just stops being offered for new use. */
|
||||
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>
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/** 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
|
||||
* normalized manufacturer catalog yet (see organized-ideas.md §3, deferred alongside
|
||||
* device_templates.manufacturer staying free text for now). */
|
||||
export type CatalogEntityType = 'device_template' | 'port_type' | 'cable_type' | 'device_category'
|
||||
* check constraint. */
|
||||
export type CatalogEntityType = 'device_template' | 'port_type' | 'cable_type' | 'device_category' | 'manufacturer'
|
||||
|
||||
/**
|
||||
* A user's request to change the public catalog — either promoting one of
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { CatalogRepository } from './CatalogRepository'
|
||||
import type { CatalogEntityType } from './SubmissionRepository'
|
||||
@@ -11,6 +11,12 @@ interface DeviceCategoryRow {
|
||||
is_public: boolean
|
||||
}
|
||||
|
||||
interface ManufacturerRow {
|
||||
id: string
|
||||
name: string
|
||||
is_public: boolean
|
||||
}
|
||||
|
||||
interface PortTypeRow {
|
||||
id: string
|
||||
name: string
|
||||
@@ -35,7 +41,7 @@ interface DeviceTemplateRow {
|
||||
id: string
|
||||
name: string
|
||||
category_id: string
|
||||
manufacturer: string | null
|
||||
manufacturer_id: string | null
|
||||
model: string | null
|
||||
cost: number | null
|
||||
is_public: boolean
|
||||
@@ -48,12 +54,17 @@ interface DeviceTemplatePortRow {
|
||||
direction: Port['direction']
|
||||
port_type_id: string
|
||||
sort_order: number
|
||||
built_in_cable: boolean
|
||||
}
|
||||
|
||||
function toDeviceCategory(row: DeviceCategoryRow): DeviceCategoryDef {
|
||||
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 {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -83,13 +94,13 @@ function toDeviceTemplate(row: DeviceTemplateRow, portRows: DeviceTemplatePortRo
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
category: row.category_id,
|
||||
manufacturer: row.manufacturer ?? undefined,
|
||||
manufacturerId: row.manufacturer_id ?? undefined,
|
||||
model: row.model ?? undefined,
|
||||
cost: row.cost ?? undefined,
|
||||
ports: portRows
|
||||
.filter((p) => p.device_template_id === row.id)
|
||||
.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,
|
||||
}
|
||||
}
|
||||
@@ -105,8 +116,9 @@ function toDeviceTemplate(row: DeviceTemplateRow, portRows: DeviceTemplatePortRo
|
||||
*/
|
||||
export class SupabaseCatalogRepository implements CatalogRepository {
|
||||
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('manufacturers').select('id, name, is_public').order('name'),
|
||||
supabase
|
||||
.from('port_types')
|
||||
.select('id, name, category, family, compatible_family_ids, max_connections, is_public')
|
||||
@@ -115,12 +127,15 @@ export class SupabaseCatalogRepository implements CatalogRepository {
|
||||
.from('cable_types')
|
||||
.select('id, name, family, family2, unit, cost_per_unit, is_public')
|
||||
.order('name'),
|
||||
supabase.from('device_templates').select('id, name, category_id, manufacturer, 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_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, built_in_cable'),
|
||||
])
|
||||
|
||||
for (const [label, result] of [
|
||||
['device categories', categories],
|
||||
['manufacturers', manufacturers],
|
||||
['port types', portTypes],
|
||||
['cable types', cableTypes],
|
||||
['device templates', templates],
|
||||
@@ -132,6 +147,7 @@ export class SupabaseCatalogRepository implements CatalogRepository {
|
||||
const portRows = (templatePorts.data ?? []) as DeviceTemplatePortRow[]
|
||||
return {
|
||||
deviceCategories: ((categories.data ?? []) as DeviceCategoryRow[]).map(toDeviceCategory),
|
||||
manufacturers: ((manufacturers.data ?? []) as ManufacturerRow[]).map(toManufacturer),
|
||||
portTypes: ((portTypes.data ?? []) as PortTypeRow[]).map(toPortType),
|
||||
cableTypes: ((cableTypes.data ?? []) as CableTypeRow[]).map(toCableType),
|
||||
deviceTemplates: ((templates.data ?? []) as DeviceTemplateRow[]).map((row) => toDeviceTemplate(row, portRows)),
|
||||
@@ -145,24 +161,81 @@ export class SupabaseCatalogRepository implements CatalogRepository {
|
||||
return user?.id ?? null
|
||||
}
|
||||
|
||||
async addDeviceCategory(name: string): Promise<DeviceCategoryDef> {
|
||||
const ownerId = await this.currentUserId()
|
||||
private async insertDeviceCategory(name: string, isPublic: boolean, ownerId: string | null): Promise<DeviceCategoryDef> {
|
||||
const id = uuid()
|
||||
const { error } = await supabase
|
||||
.from('device_categories')
|
||||
.insert({ id, name, is_public: false, owner_id: ownerId })
|
||||
const { error } = await supabase.from('device_categories').insert({ id, name, is_public: isPublic, owner_id: ownerId })
|
||||
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> {
|
||||
const ownerId = await this.currentUserId()
|
||||
async addDeviceCategory(name: string): Promise<DeviceCategoryDef> {
|
||||
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 { error } = await supabase
|
||||
.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)
|
||||
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> {
|
||||
@@ -176,14 +249,33 @@ export class SupabaseCatalogRepository implements CatalogRepository {
|
||||
if (error) console.error('Failed to update port type in Supabase', error)
|
||||
}
|
||||
|
||||
async addCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType> {
|
||||
const ownerId = await this.currentUserId()
|
||||
async removePortType(id: string): Promise<void> {
|
||||
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 { error } = await supabase
|
||||
.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)
|
||||
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> {
|
||||
@@ -197,14 +289,25 @@ export class SupabaseCatalogRepository implements CatalogRepository {
|
||||
if (error) console.error('Failed to update cable type in Supabase', error)
|
||||
}
|
||||
|
||||
async addDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate> {
|
||||
const ownerId = await this.currentUserId()
|
||||
async removeCableType(id: string): Promise<void> {
|
||||
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 { 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) {
|
||||
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 }))
|
||||
if (ports.length > 0) {
|
||||
@@ -216,18 +319,27 @@ export class SupabaseCatalogRepository implements CatalogRepository {
|
||||
direction: port.direction,
|
||||
port_type_id: port.portTypeId,
|
||||
sort_order: port.sortOrder,
|
||||
built_in_cable: port.builtInCable ?? false,
|
||||
})),
|
||||
)
|
||||
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> {
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (patch.name !== undefined) payload.name = patch.name
|
||||
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.cost !== undefined) payload.cost = patch.cost
|
||||
if (Object.keys(payload).length > 0) {
|
||||
@@ -250,6 +362,7 @@ export class SupabaseCatalogRepository implements CatalogRepository {
|
||||
direction: port.direction,
|
||||
port_type_id: port.portTypeId,
|
||||
sort_order: index,
|
||||
built_in_cable: port.builtInCable ?? false,
|
||||
})),
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
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> {
|
||||
const table = CATALOG_TABLE_BY_ENTITY_TYPE[entityType]
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export const CATALOG_TABLE_BY_ENTITY_TYPE: Record<CatalogEntityType, string> = {
|
||||
port_type: 'port_types',
|
||||
cable_type: 'cable_types',
|
||||
device_category: 'device_categories',
|
||||
manufacturer: 'manufacturers',
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
export function manufacturerToRow(name: string): Record<string, unknown> {
|
||||
return { name }
|
||||
}
|
||||
|
||||
export function deviceTemplateToRow(template: Omit<DeviceTemplate, 'id' | 'custom'>): Record<string, unknown> {
|
||||
return {
|
||||
name: template.name,
|
||||
category_id: template.category,
|
||||
manufacturer: template.manufacturer ?? null,
|
||||
manufacturer_id: template.manufacturerId ?? null,
|
||||
model: template.model ?? null,
|
||||
cost: template.cost ?? null,
|
||||
ports: template.ports.map((port, index) => ({
|
||||
@@ -57,6 +62,7 @@ export function deviceTemplateToRow(template: Omit<DeviceTemplate, 'id' | 'custo
|
||||
direction: port.direction,
|
||||
port_type_id: port.portTypeId,
|
||||
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'> {
|
||||
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 {
|
||||
name: row.name 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,
|
||||
cost: (row.cost as number | null) ?? undefined,
|
||||
ports: ports.map((port, index) => ({
|
||||
@@ -104,6 +116,7 @@ export function rowToDeviceTemplateFields(row: Record<string, unknown>): Omit<De
|
||||
name: port.name,
|
||||
direction: port.direction,
|
||||
portTypeId: port.port_type_id,
|
||||
builtInCable: port.built_in_cable ?? false,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
+24
-3
@@ -1,4 +1,4 @@
|
||||
import { allCableTypes } from './diagram'
|
||||
import { allCableTypes, manufacturerName } from './diagram'
|
||||
import type { CableType, Catalog, Connection, Device, Diagram } from './types'
|
||||
|
||||
/** 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 {
|
||||
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
|
||||
@@ -82,8 +82,25 @@ function connectionCost(connection: Connection, cableType: CableType | 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 {
|
||||
const cableTypes = new Map(allCableTypes(catalog).map((ct) => [ct.id, ct]))
|
||||
const builtIn = builtInPortIds(diagram.devices)
|
||||
|
||||
const cableLines = new Map<string, BomCableLine>()
|
||||
// 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>>()
|
||||
|
||||
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 name = cableType?.name ?? 'Unknown Cable'
|
||||
const unit = cableType?.unit ?? 'ft'
|
||||
@@ -161,7 +182,7 @@ export function computeBom(diagram: Diagram, catalog: Catalog): Bom {
|
||||
key,
|
||||
name: device.name,
|
||||
category: device.category,
|
||||
manufacturer: device.manufacturer,
|
||||
manufacturer: manufacturerName(catalog, device.manufacturerId),
|
||||
model: device.model,
|
||||
count: 0,
|
||||
totalCost: 0,
|
||||
|
||||
+26
-2
@@ -1,5 +1,5 @@
|
||||
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[] {
|
||||
return catalog.portTypes
|
||||
@@ -35,6 +35,22 @@ export function deviceCategoryName(catalog: Catalog, categoryId: string): string
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
/** 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
|
||||
* plus anything it explicitly lists as also-compatible. Shared with
|
||||
* `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,
|
||||
templateId: template.id,
|
||||
category: template.category,
|
||||
manufacturer: template.manufacturer,
|
||||
manufacturerId: template.manufacturerId,
|
||||
model: template.model,
|
||||
ports: template.ports.map((port) => ({ ...port, id: uuid() })),
|
||||
cost: template.cost,
|
||||
|
||||
+25
-2
@@ -28,6 +28,16 @@ export interface DeviceCategoryDef {
|
||||
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
|
||||
* between two ports is decided by comparing `family` (plus optional
|
||||
@@ -54,6 +64,14 @@ export interface Port {
|
||||
name: string
|
||||
direction: PortDirection
|
||||
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). */
|
||||
@@ -61,7 +79,9 @@ export interface DeviceTemplate {
|
||||
id: string
|
||||
name: string
|
||||
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
|
||||
ports: Port[]
|
||||
/** Suggested default cost, copied onto each placed Device at creation time
|
||||
@@ -82,7 +102,9 @@ export interface Device {
|
||||
name: string
|
||||
templateId?: string
|
||||
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
|
||||
notes?: string
|
||||
ports: Port[]
|
||||
@@ -156,5 +178,6 @@ export interface Catalog {
|
||||
portTypes: PortType[]
|
||||
cableTypes: CableType[]
|
||||
deviceCategories: DeviceCategoryDef[]
|
||||
manufacturers: Manufacturer[]
|
||||
deviceTemplates: DeviceTemplate[]
|
||||
}
|
||||
|
||||
+153
-2
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand'
|
||||
import type { CableType, Catalog, DeviceTemplate, PortType } from '../domain/types'
|
||||
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 type { CatalogEntityType } from '../data/SubmissionRepository'
|
||||
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 {
|
||||
catalog: Catalog
|
||||
@@ -39,12 +39,23 @@ interface CatalogStoreState {
|
||||
loadCatalog: () => Promise<void>
|
||||
|
||||
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>
|
||||
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>
|
||||
updateCustomCableType: (id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>) => Promise<void>
|
||||
removeCustomCableType: (id: string) => Promise<void>
|
||||
|
||||
addCustomDeviceTemplate: (template: 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
|
||||
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
|
||||
* organized-ideas.md §6 — bypasses the submission/review workflow
|
||||
* entirely (RLS's is_admin() clause is what actually permits it). */
|
||||
adminUpdatePortType: (id: string, patch: Partial<Omit<PortType, '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>
|
||||
adminUpdateDeviceCategory: (id: string, name: string) => Promise<void>
|
||||
adminUpdateManufacturer: (id: string, name: string) => Promise<void>
|
||||
/** Unpublishes (is_public -> false) rather than deletes — see
|
||||
* CatalogRepository.adminUnpublish. */
|
||||
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) => ({
|
||||
@@ -80,11 +111,70 @@ export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
|
||||
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) => {
|
||||
const created = await repository.addPortType(portType)
|
||||
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) => {
|
||||
await repository.updatePortType(id, patch)
|
||||
set((state) => ({
|
||||
@@ -97,11 +187,21 @@ export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
|
||||
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) => {
|
||||
const created = await repository.addCableType(cableType)
|
||||
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) => {
|
||||
await repository.updateCableType(id, patch)
|
||||
set((state) => ({
|
||||
@@ -114,11 +214,21 @@ export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
|
||||
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) => {
|
||||
const created = await repository.addDeviceTemplate(template)
|
||||
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) => {
|
||||
await repository.updateDeviceTemplate(id, patch)
|
||||
set((state) => ({
|
||||
@@ -174,8 +284,49 @@ export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
|
||||
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) => {
|
||||
await repository.adminUnpublish(entityType, id)
|
||||
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;
|
||||
Reference in New Issue
Block a user