Files
av-planner/src/components/palette/DeviceTemplateEditor.tsx
T
aarbit a0c598cb1b Add the user-submission flow for the catalog
Lets users submit a private catalog entry (port type, cable type, device
template) for promotion to the public catalog, or suggest an edit to an
existing public entry — both go into the catalog_submissions review queue
per organized-ideas.md §3. No Admin review UI yet (next sub-phase); this
covers the submitter's side only.

- data/SubmissionRepository + SupabaseSubmissionRepository: submit,
  resubmit, withdraw, list-mine, backed by the existing catalog_submissions
  RLS policies (no schema changes needed).
- data/catalogRowMapping.ts: shared domain<->row mappers, in both
  directions, so a submission's proposed_data is always shaped like the
  underlying table row (what an eventual admin-approval would write
  directly) and can be turned back into form-editable fields for revision.
- state/submissionStore.ts: mySubmissions + submit/resubmit/withdraw, plus
  syncProposedData — called from catalogStore's updateCustom* actions so a
  submission about your own still-private entry never goes stale relative
  to it (edits from the library and from My Submissions are the same
  action and always agree).
- UI: "Submit"/"Suggest edit" wired into PortTypeManager, CableTypeManager,
  DevicePalette/DeviceTemplateEditor; new MySubmissionsModal (opened from
  TopBar, with a pending-count badge) shows status, rejection reasons, and
  lets you edit/resubmit or withdraw.
- Deliberately deferred: device_category submissions (no listing UI to
  hang a button on yet) and the normalized manufacturer catalog.

Verified: tsc -b and oxlint clean; supabase db reset + 23/23 pgTAP RLS
tests still pass (no schema changes this round); manually tested submit,
suggest-edit, edit-from-either-side sync, reject/resubmit, and withdraw.
2026-09-08 10:37:38 -05:00

360 lines
15 KiB
TypeScript

import { useMemo, useState } from 'react'
import { deviceTemplateToRow } from '../../data/catalogRowMapping'
import { PORT_CATEGORIES, PORT_DIRECTIONS } from '../../domain/constants'
import { allPortTypes, expandCompatibleFamilyIds, incrementPortName } from '../../domain/project'
import type { DeviceTemplate, PortDirection } from '../../domain/types'
import { useCatalogStore } from '../../state/catalogStore'
import { useSubmissionStore } from '../../state/submissionStore'
import CategorySelect from '../common/CategorySelect'
import Modal from '../common/Modal'
interface DraftPort {
key: string
name: string
direction: PortDirection
portTypeId: string
}
let draftKeySeq = 0
function nextDraftKey() {
draftKeySeq += 1
return `draft-${draftKeySeq}`
}
export default function DeviceTemplateEditor({
template,
submissionMode = false,
resubmitId,
onClose,
onSaved,
}: {
/** When set, edits/prefills from this existing template instead of creating a new one. */
template?: DeviceTemplate
/** When true, `template` is a public entry you don't own — saving submits
* a suggested edit for Admin review instead of mutating it directly
* (which RLS wouldn't allow anyway). */
submissionMode?: boolean
/** When set, saving revises this existing submission (of your own,
* pending or rejected) instead of creating a new one — see
* MySubmissionsModal's "Edit & resubmit". Takes precedence over
* `submissionMode` if both are somehow 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
}) {
const catalog = useCatalogStore((s) => s.catalog)
const addCustomDeviceTemplate = useCatalogStore((s) => s.addCustomDeviceTemplate)
const updateCustomDeviceTemplate = useCatalogStore((s) => s.updateCustomDeviceTemplate)
const addCustomPortType = useCatalogStore((s) => s.addCustomPortType)
const submitForReview = useSubmissionStore((s) => s.submitForReview)
const resubmit = useSubmissionStore((s) => s.resubmit)
const portTypes = useMemo(
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
[catalog],
)
const [name, setName] = useState(template?.name ?? '')
const [category, setCategory] = useState<string>(template?.category ?? 'other')
const [manufacturer, setManufacturer] = useState(template?.manufacturer ?? '')
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 })) ?? [
{ key: nextDraftKey(), name: 'Port 1', direction: 'output', portTypeId: portTypes[0]?.id ?? '' },
],
)
const [newPortTypeOpen, setNewPortTypeOpen] = useState(false)
const [newPortTypeName, setNewPortTypeName] = useState('')
const [newPortTypeFamily, setNewPortTypeFamily] = useState('')
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 toggleAlsoCompatible = (portTypeId: string) => {
setAlsoCompatibleWith((prev) => {
const next = new Set(prev)
if (next.has(portTypeId)) next.delete(portTypeId)
else next.add(portTypeId)
return next
})
}
const updatePort = (key: string, patch: Partial<DraftPort>) => {
setPorts((prev) => prev.map((p) => (p.key === key ? { ...p, ...patch } : p)))
}
const addPortRow = () => {
const key = nextDraftKey()
setPorts((prev) => [
...prev,
{ key, name: `Port ${prev.length + 1}`, direction: 'output', portTypeId: portTypes[0]?.id ?? '' },
])
setFocusKey(key)
}
const clonePortRow = (sourceKey: string) => {
const key = nextDraftKey()
setPorts((prev) => {
const index = prev.findIndex((p) => p.key === sourceKey)
if (index === -1) return prev
const source = prev[index]
const clone: DraftPort = { ...source, key, name: incrementPortName(source.name) }
return [...prev.slice(0, index + 1), clone, ...prev.slice(index + 1)]
})
setFocusKey(key)
}
const removePortRow = (key: string) => {
setPorts((prev) => prev.filter((p) => p.key !== key))
}
const handleCreatePortType = () => {
if (!newPortTypeName.trim()) return
const compatibleFamilyIds = expandCompatibleFamilyIds([...alsoCompatibleWith], portTypes)
addCustomPortType({
name: newPortTypeName.trim(),
category: newPortTypeCategory,
family: (newPortTypeFamily.trim() || newPortTypeName.trim()).toLowerCase().replace(/\s+/g, '-'),
...(compatibleFamilyIds.length > 0 ? { compatibleFamilyIds } : {}),
})
setNewPortTypeName('')
setNewPortTypeFamily('')
setAlsoCompatibleWith(new Set())
setNewPortTypeOpen(false)
}
const canSave = name.trim().length > 0 && ports.length > 0 && ports.every((p) => p.portTypeId)
const handleSave = () => {
if (!canSave) return
const fields = {
name: name.trim(),
category,
manufacturer: manufacturer.trim() || undefined,
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 })),
}
if (resubmitId) {
resubmit(resubmitId, deviceTemplateToRow(fields))
} else if (submissionMode && template) {
submitForReview('device_template', template.id, deviceTemplateToRow(fields))
} else if (template) {
updateCustomDeviceTemplate(template.id, fields)
} else {
addCustomDeviceTemplate(fields)
}
onSaved?.(category)
onClose()
}
return (
<Modal
title={resubmitId ? 'Revise Submission' : submissionMode ? 'Suggest an Edit' : template ? 'Edit Custom Device' : 'New Custom Device'}
onClose={onClose}
width="max-w-xl"
>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<label className="col-span-2 text-xs font-medium text-slate-600">
Name
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
placeholder="e.g. Living Room Display"
/>
</label>
<label className="text-xs font-medium text-slate-600">
Category
<CategorySelect value={category} onChange={setCategory} />
</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"
/>
</label>
<label className="text-xs font-medium text-slate-600">
Model
<input
value={model}
onChange={(e) => setModel(e.target.value)}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
/>
</label>
<label className="text-xs font-medium text-slate-600">
Suggested cost
<div className="mt-1 flex items-center gap-1">
<span className="text-xs text-slate-500">$</span>
<input
type="number"
min={0}
step={0.01}
value={cost}
onChange={(e) => setCost(e.target.value)}
className="w-full rounded border border-slate-300 px-2 py-1 text-sm"
placeholder="e.g. 249.00"
/>
</div>
</label>
</div>
<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">
<input
ref={(el) => {
if (el && focusKey === port.key) {
el.focus()
setFocusKey(null)
}
}}
value={port.name}
onChange={(e) => updatePort(port.key, { name: e.target.value })}
className="w-28 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
placeholder="Port name"
/>
<select
value={port.direction}
onChange={(e) => updatePort(port.key, { direction: e.target.value as PortDirection })}
className="rounded border border-slate-300 px-1 py-1 text-xs"
>
{PORT_DIRECTIONS.map((d) => (
<option key={d.value} value={d.value}>
{d.label}
</option>
))}
</select>
<select
value={port.portTypeId}
onChange={(e) => updatePort(port.key, { portTypeId: e.target.value })}
className="w-32 min-w-0 flex-1 rounded border border-slate-300 px-1 py-1 text-xs"
>
{portTypes.map((pt) => (
<option key={pt.id} value={pt.id}>
{pt.name}
</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>
</div>
))}
{ports.length === 0 && <p className="text-[11px] italic text-slate-400">No ports yet.</p>}
</div>
<button onClick={addPortRow} className="mt-1.5 text-[11px] font-medium text-indigo-600 hover:underline">
+ Add port
</button>
<div className="mt-2 border-t border-slate-100 pt-2">
{!newPortTypeOpen ? (
<button
onClick={() => setNewPortTypeOpen(true)}
className="text-[11px] font-medium text-indigo-600 hover:underline"
>
+ Define a new connector type
</button>
) : (
<div className="space-y-1.5 rounded border border-slate-200 bg-slate-50 p-2">
<div className="flex gap-1.5">
<input
value={newPortTypeName}
onChange={(e) => setNewPortTypeName(e.target.value)}
placeholder="Connector name (e.g. Zigbee)"
className="flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<select
value={newPortTypeCategory}
onChange={(e) => setNewPortTypeCategory(e.target.value as (typeof PORT_CATEGORIES)[number]['value'])}
className="rounded border border-slate-300 px-1 py-1 text-xs"
>
{PORT_CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</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>
<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
balanced or unbalanced):
</p>
<div className="mt-1 max-h-24 overflow-y-auto rounded border border-slate-200 bg-white p-1.5">
{portTypes.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={() => toggleAlsoCompatible(pt.id)}
/>
{pt.name}
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<button onClick={() => setNewPortTypeOpen(false)} className="text-[11px] text-slate-500">
Cancel
</button>
<button
onClick={handleCreatePortType}
className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500"
>
Add connector type
</button>
</div>
</div>
)}
</div>
</div>
<div className="flex justify-end gap-2 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">
Cancel
</button>
<button
onClick={handleSave}
disabled={!canSave}
className="rounded bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
>
{resubmitId ? 'Resubmit' : submissionMode ? 'Submit for review' : template ? 'Save changes' : 'Save device'}
</button>
</div>
</div>
</Modal>
)
}