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.
254 lines
12 KiB
TypeScript
254 lines
12 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import { deviceTemplateToRow } from '../../data/catalogRowMapping'
|
|
import { allDeviceCategories, allDeviceTemplates, hiddenDeviceTemplates } from '../../domain/project'
|
|
import type { DeviceTemplate } from '../../domain/types'
|
|
import { useCatalogStore } from '../../state/catalogStore'
|
|
import { useProjectStore } from '../../state/projectStore'
|
|
import { useSubmissionStore } from '../../state/submissionStore'
|
|
import { TEMPLATE_DRAG_MIME } from '../canvas/FlowCanvas'
|
|
import Chevron from '../common/Chevron'
|
|
import ConnectorLibraryModal from './ConnectorLibraryModal'
|
|
import DeviceTemplateEditor from './DeviceTemplateEditor'
|
|
|
|
type EditorTarget = { mode: 'new' } | { mode: 'edit'; template: DeviceTemplate } | { mode: 'suggestEdit'; template: DeviceTemplate }
|
|
|
|
export default function DevicePalette() {
|
|
const project = useProjectStore((s) => s.project)
|
|
const addDeviceFromTemplate = useProjectStore((s) => s.addDeviceFromTemplate)
|
|
const catalog = useCatalogStore((s) => s.catalog)
|
|
const hiddenPublicIds = useCatalogStore((s) => s.hiddenPublicDeviceTemplateIds)
|
|
const removeCustomDeviceTemplate = useCatalogStore((s) => s.removeCustomDeviceTemplate)
|
|
const hidePublicDeviceTemplate = useCatalogStore((s) => s.hidePublicDeviceTemplate)
|
|
const restorePublicDeviceTemplate = useCatalogStore((s) => s.restorePublicDeviceTemplate)
|
|
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
|
|
const submitForReview = useSubmissionStore((s) => s.submitForReview)
|
|
const [editorTarget, setEditorTarget] = useState<EditorTarget | null>(null)
|
|
const [portTypesOpen, setPortTypesOpen] = useState(false)
|
|
const [hiddenListOpen, setHiddenListOpen] = useState(false)
|
|
// 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())
|
|
|
|
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)
|
|
}
|
|
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 next = new Set(prev)
|
|
if (next.has(categoryId)) next.delete(categoryId)
|
|
else next.add(categoryId)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const expandCategory = (categoryId: string) => {
|
|
setExpandedCategories((prev) => new Set(prev).add(categoryId))
|
|
}
|
|
|
|
const handleDragStart = (event: React.DragEvent, template: DeviceTemplate) => {
|
|
event.dataTransfer.setData(TEMPLATE_DRAG_MIME, template.id)
|
|
event.dataTransfer.effectAllowed = 'move'
|
|
}
|
|
|
|
const handleQuickAdd = (template: DeviceTemplate) => {
|
|
// Cascade placement so repeated quick-adds don't stack exactly on top of each other.
|
|
const offset = (project.devices.length % 8) * 24
|
|
addDeviceFromTemplate(catalog, template.id, { x: 80 + offset, y: 80 + offset })
|
|
}
|
|
|
|
const handleDelete = (template: DeviceTemplate) => {
|
|
if (!template.custom) {
|
|
const confirmed = window.confirm(
|
|
`Hide "${template.name}" from your device library? It's a public default, so you can bring it back later from "Show hidden defaults" at the bottom of this panel. Devices already placed on the canvas from it won't be affected.`,
|
|
)
|
|
if (confirmed) hidePublicDeviceTemplate(template.id)
|
|
return
|
|
}
|
|
const confirmed = window.confirm(
|
|
`Delete "${template.name}" from your device library? Devices already placed on the canvas from it won't be affected.`,
|
|
)
|
|
if (confirmed) removeCustomDeviceTemplate(template.id)
|
|
}
|
|
|
|
const hiddenDefaults = useMemo(() => hiddenDeviceTemplates(catalog, hiddenPublicIds), [catalog, hiddenPublicIds])
|
|
|
|
const pendingEntityIds = useMemo(
|
|
() =>
|
|
new Set(
|
|
mySubmissions.filter((s) => s.entityType === 'device_template' && s.status === 'pending').map((s) => s.entityId),
|
|
),
|
|
[mySubmissions],
|
|
)
|
|
|
|
const handleSubmitForPublic = (template: DeviceTemplate) => {
|
|
submitForReview('device_template', template.id, deviceTemplateToRow(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>
|
|
<button
|
|
onClick={() => setEditorTarget({ mode: 'new' })}
|
|
className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500"
|
|
>
|
|
+ Custom
|
|
</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="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
|
|
onDragStart={(e) => handleDragStart(e, template)}
|
|
className="flex cursor-grab items-center justify-between rounded border border-slate-200 bg-white px-2 py-1.5 text-xs shadow-sm 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>
|
|
) : (
|
|
<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>
|
|
<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>
|
|
)
|
|
})}
|
|
</div>
|
|
{hiddenDefaults.length > 0 && (
|
|
<div className="border-t border-slate-200 px-2 py-1.5">
|
|
<button
|
|
onClick={() => setHiddenListOpen((v) => !v)}
|
|
className="flex w-full items-center justify-between rounded px-1.5 py-1 text-left text-[11px] text-slate-500 hover:bg-slate-100"
|
|
>
|
|
<span>
|
|
{hiddenListOpen ? 'Hide' : 'Show'} hidden defaults ({hiddenDefaults.length})
|
|
</span>
|
|
<Chevron expanded={hiddenListOpen} className="text-slate-500" />
|
|
</button>
|
|
{hiddenListOpen && (
|
|
<ul className="mt-1 space-y-1">
|
|
{hiddenDefaults.map((template) => (
|
|
<li key={template.id} className="flex items-center justify-between rounded bg-white px-2 py-1 text-xs">
|
|
<span className="truncate text-slate-600">{template.name}</span>
|
|
<button
|
|
onClick={() => restorePublicDeviceTemplate(template.id)}
|
|
className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
|
|
>
|
|
Restore
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
{editorTarget && (
|
|
<DeviceTemplateEditor
|
|
template={editorTarget.mode === 'new' ? undefined : editorTarget.template}
|
|
submissionMode={editorTarget.mode === 'suggestEdit'}
|
|
onClose={() => setEditorTarget(null)}
|
|
onSaved={(categoryId) => expandCategory(categoryId)}
|
|
/>
|
|
)}
|
|
{portTypesOpen && <ConnectorLibraryModal onClose={() => setPortTypesOpen(false)} />}
|
|
</aside>
|
|
)
|
|
}
|