From 29c3ed46a30bd9cb641d4f63fe10021bb2dabb59 Mon Sep 17 00:00:00 2001 From: aarbit Date: Mon, 28 Sep 2026 10:04:05 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x --- src/components/common/CategorySelect.tsx | 9 +- src/components/common/DropdownMenu.tsx | 74 +++ src/components/common/FamilySelect.tsx | 132 +++++ src/components/common/InfoTooltip.tsx | 83 +++ src/components/common/ManufacturerSelect.tsx | 100 ++++ src/components/inspector/DeviceInspector.tsx | 59 ++- src/components/palette/CableTypeManager.tsx | 93 +++- .../palette/CategoryLibraryModal.tsx | 44 ++ .../palette/CategoryManufacturerPanel.tsx | 374 +++++++++++++ src/components/palette/DevicePalette.tsx | 501 +++++++++++++----- .../palette/DeviceTemplateEditor.tsx | 147 +++-- src/components/palette/PortTypeManager.tsx | 154 ++++-- .../submissions/AdminReviewModal.tsx | 34 +- .../submissions/MySubmissionsModal.tsx | 15 +- .../submissions/catalogEntityLookup.ts | 10 +- src/components/submissions/submissionDiff.ts | 4 +- src/data/CatalogRepository.ts | 51 +- src/data/SubmissionRepository.ts | 6 +- src/data/SupabaseCatalogRepository.ts | 199 ++++++- src/data/catalogRowMapping.ts | 19 +- src/domain/bom.ts | 27 +- src/domain/diagram.ts | 28 +- src/domain/types.ts | 27 +- src/state/catalogStore.ts | 155 +++++- .../20260915000000_normalize_manufacturer.sql | 30 ++ ...0000_category_manufacturer_submissions.sql | 61 +++ ...00000_own_category_manufacturer_delete.sql | 76 +++ .../20260919000000_built_in_cable.sql | 9 + 28 files changed, 2201 insertions(+), 320 deletions(-) create mode 100644 src/components/common/DropdownMenu.tsx create mode 100644 src/components/common/FamilySelect.tsx create mode 100644 src/components/common/InfoTooltip.tsx create mode 100644 src/components/common/ManufacturerSelect.tsx create mode 100644 src/components/palette/CategoryLibraryModal.tsx create mode 100644 src/components/palette/CategoryManufacturerPanel.tsx create mode 100644 supabase/migrations/20260915000000_normalize_manufacturer.sql create mode 100644 supabase/migrations/20260917000000_category_manufacturer_submissions.sql create mode 100644 supabase/migrations/20260918000000_own_category_manufacturer_delete.sql create mode 100644 supabase/migrations/20260919000000_built_in_cable.sql diff --git a/src/components/common/CategorySelect.tsx b/src/components/common/CategorySelect.tsx index f1560b3..78d5cda 100644 --- a/src/components/common/CategorySelect.tsx +++ b/src/components/common/CategorySelect.tsx @@ -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('') diff --git a/src/components/common/DropdownMenu.tsx b/src/components/common/DropdownMenu.tsx new file mode 100644 index 0000000..374575b --- /dev/null +++ b/src/components/common/DropdownMenu.tsx @@ -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(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 ( +
+ + {open && ( +
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} +
+ )} +
+ ) +} + +/** 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 ( + + ) +} diff --git a/src/components/common/FamilySelect.tsx b/src/components/common/FamilySelect.tsx new file mode 100644 index 0000000..573dc0d --- /dev/null +++ b/src/components/common/FamilySelect.tsx @@ -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() + 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 = ( +
+ Family + +
+ ) + + if (creating) { + return ( +
+ {label} +
+ 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}`} + /> + + {families.length > 0 && ( + + )} +
+
+ ) + } + + return ( +
+ {label} + +
+ ) +} diff --git a/src/components/common/InfoTooltip.tsx b/src/components/common/InfoTooltip.tsx new file mode 100644 index 0000000..49a89dc --- /dev/null +++ b/src/components/common/InfoTooltip.tsx @@ -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(null) + const popoverRef = useRef(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 ( + <> + + {open && + position && + createPortal( +
+ {text} +
, + document.body, + )} + + ) +} diff --git a/src/components/common/ManufacturerSelect.tsx b/src/components/common/ManufacturerSelect.tsx new file mode 100644 index 0000000..3d8b5c2 --- /dev/null +++ b/src/components/common/ManufacturerSelect.tsx @@ -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 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" + /> + + + + ) + } + + return ( + + ) +} diff --git a/src/components/inspector/DeviceInspector.tsx b/src/components/inspector/DeviceInspector.tsx index 6452611..15ab0c7 100644 --- a/src/components/inspector/DeviceInspector.tsx +++ b/src/components/inspector/DeviceInspector.tsx @@ -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 }) { + {canPublishDirectly && ( + + )} +

Ports

{ports.map((port) => ( -
+
{ if (el && focusKey === port.key) { @@ -263,22 +314,37 @@ export default function DeviceTemplateEditor({ ))} - - + {/* 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. */} +
+ + + +
))} {ports.length === 0 &&

No ports yet.

} @@ -316,18 +382,11 @@ export default function DeviceTemplateEditor({ ))}
- 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" - /> -

- Two ports can be wired together only if their connector type shares the same family. -

+ +

Leave this as a new family and it'll default to the connector name.

- 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):

@@ -376,7 +435,9 @@ export default function DeviceTemplateEditor({ ? 'Submit for review' : template ? 'Save changes' - : 'Save device'} + : canPublishDirectly && publishDirectly + ? 'Publish device' + : 'Save device'}
diff --git a/src/components/palette/PortTypeManager.tsx b/src/components/palette/PortTypeManager.tsx index 34adfb1..b99a62e 100644 --- a/src/components/palette/PortTypeManager.tsx +++ b/src/components/palette/PortTypeManager.tsx @@ -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) => void + onSave: (fields: Omit, 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>(() => { + 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({ ))}
- setFamily(e.target.value)} - placeholder="Compatibility family" - className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs" - /> + + {!portType &&

Leave this as a new family and it'll default to the connector name.

}
-

Also compatible with:

+

Can accept connections from:

{allTypes - .filter((pt) => pt.id !== portType.id) + .filter((pt) => pt.id !== portType?.id) .map((pt) => (
+ {showPublishToggle && ( + + )}
@@ -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(null) const [suggestingId, setSuggestingId] = useState(null) const [adminEditingId, setAdminEditingId] = useState(null) @@ -143,6 +171,12 @@ export default function PortTypesPanel() { return names.length > 0 ? names.join(', ') : null } + const handleCreate = (fields: Omit, 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) => { + 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 (
-

- Custom ({customTypes.length}) -

- {customTypes.length === 0 ? ( +
+

+ Custom ({customTypes.length}) +

+ {!creating && ( + + )} +
+ {creating && ( +
+ setCreating(false)} + onSave={handleCreate} + /> +
+ )} + {customTypes.length === 0 && !creating ? (

- 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.

) : (
@@ -201,7 +282,7 @@ export default function PortTypesPanel() {
{pt.name}
family: {pt.family} - {describeCompatibility(pt) ? ` · also fits: ${describeCompatibility(pt)}` : ''} + {describeCompatibility(pt) ? ` · can accept: ${describeCompatibility(pt)}` : ''}
@@ -224,6 +305,12 @@ export default function PortTypesPanel() { > Edit +
), @@ -265,7 +352,7 @@ export default function PortTypesPanel() { {pt.name} {describeCompatibility(pt) && ( - · also fits: {describeCompatibility(pt)} + · can accept: {describeCompatibility(pt)} )} {isAdmin ? ( @@ -282,6 +369,13 @@ export default function PortTypesPanel() { > Unpublish +
) : pendingEntityIds.has(pt.id) ? ( diff --git a/src/components/submissions/AdminReviewModal.tsx b/src/components/submissions/AdminReviewModal.tsx index 1df1531..ed20eed 100644 --- a/src/components/submissions/AdminReviewModal.tsx +++ b/src/components/submissions/AdminReviewModal.tsx @@ -14,6 +14,7 @@ const ENTITY_TYPE_LABELS: Record = { 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) diff --git a/src/components/submissions/MySubmissionsModal.tsx b/src/components/submissions/MySubmissionsModal.tsx index 0d921ec..f50852f 100644 --- a/src/components/submissions/MySubmissionsModal.tsx +++ b/src/components/submissions/MySubmissionsModal.tsx @@ -17,6 +17,7 @@ const ENTITY_TYPE_LABELS: Record = { 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(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 (
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" />