From a0c598cb1b62df6b6aefc4c66300aefafae66f1b Mon Sep 17 00:00:00 2001 From: aarbit Date: Tue, 8 Sep 2026 10:37:38 -0500 Subject: [PATCH] Add the user-submission flow for the catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/layout/AppShell.tsx | 5 +- src/components/layout/TopBar.tsx | 23 +- src/components/palette/CableTypeManager.tsx | 148 ++++---- src/components/palette/DevicePalette.tsx | 51 ++- .../palette/DeviceTemplateEditor.tsx | 31 +- src/components/palette/PortTypeManager.tsx | 114 +++++-- src/components/palette/cableTypeFormFields.ts | 29 ++ .../submissions/MySubmissionsModal.tsx | 321 ++++++++++++++++++ src/data/SubmissionRepository.ts | 59 ++++ src/data/SupabaseCatalogRepository.ts | 39 +-- src/data/SupabaseSubmissionRepository.ts | 93 +++++ src/data/catalogRowMapping.ts | 102 ++++++ src/state/catalogStore.ts | 8 + src/state/submissionStore.ts | 64 ++++ 14 files changed, 968 insertions(+), 119 deletions(-) create mode 100644 src/components/palette/cableTypeFormFields.ts create mode 100644 src/components/submissions/MySubmissionsModal.tsx create mode 100644 src/data/SubmissionRepository.ts create mode 100644 src/data/SupabaseSubmissionRepository.ts create mode 100644 src/data/catalogRowMapping.ts create mode 100644 src/state/submissionStore.ts diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 73e15c0..bd8a5ef 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react' import { useCatalogStore } from '../../state/catalogStore' import { useProjectStore } from '../../state/projectStore' +import { useSubmissionStore } from '../../state/submissionStore' import FlowCanvas from '../canvas/FlowCanvas' import DevicePalette from '../palette/DevicePalette' import ConnectionErrorBanner from './ConnectionErrorBanner' @@ -12,11 +13,13 @@ export default function AppShell() { const loadInitialDiagram = useProjectStore((s) => s.loadInitialDiagram) const isCatalogLoaded = useCatalogStore((s) => s.isLoaded) const loadCatalog = useCatalogStore((s) => s.loadCatalog) + const loadMySubmissions = useSubmissionStore((s) => s.loadMySubmissions) useEffect(() => { loadInitialDiagram() loadCatalog() - }, [loadInitialDiagram, loadCatalog]) + loadMySubmissions() + }, [loadInitialDiagram, loadCatalog, loadMySubmissions]) if (!isLoaded || !isCatalogLoaded) { return
Loading…
diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index d2aa018..d69a977 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -1,17 +1,26 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { downloadProjectFile, readProjectFile, ProjectImportError } from '../../data/exportImport' import { supabase } from '../../data/supabaseClient' import { useProjectStore } from '../../state/projectStore' +import { useSubmissionStore } from '../../state/submissionStore' +import MySubmissionsModal from '../submissions/MySubmissionsModal' import DiagramManagerModal from './DiagramManagerModal' export default function TopBar() { const project = useProjectStore((s) => s.project) const renameProject = useProjectStore((s) => s.renameProject) const importProject = useProjectStore((s) => s.importProject) + const mySubmissions = useSubmissionStore((s) => s.mySubmissions) const fileInputRef = useRef(null) const [diagramManagerOpen, setDiagramManagerOpen] = useState(false) + const [submissionsOpen, setSubmissionsOpen] = useState(false) const [username, setUsername] = useState(null) + const pendingSubmissionCount = useMemo( + () => mySubmissions.filter((s) => s.status === 'pending').length, + [mySubmissions], + ) + // One-time fetch — nothing else in the app changes your own username after // CompleteProfileScreen sets it, so there's no need to keep this live. useEffect(() => { @@ -60,6 +69,17 @@ export default function TopBar() { > Diagrams + @@ -79,6 +99,7 @@ export default function TopBar() { {diagramManagerOpen && setDiagramManagerOpen(false)} />} + {submissionsOpen && setSubmissionsOpen(false)} />} ) } diff --git a/src/components/palette/CableTypeManager.tsx b/src/components/palette/CableTypeManager.tsx index 9bba471..fd3bf84 100644 --- a/src/components/palette/CableTypeManager.tsx +++ b/src/components/palette/CableTypeManager.tsx @@ -1,7 +1,10 @@ import { useMemo, useState } from 'react' import { allCableTypes, allPortTypes } from '../../domain/project' import type { CableType, PortType } from '../../domain/types' +import { cableTypeToRow } from '../../data/catalogRowMapping' import { useCatalogStore } from '../../state/catalogStore' +import { useSubmissionStore } from '../../state/submissionStore' +import { resolveCableTypeFields, type CableTypeFormFields } from './cableTypeFormFields' const UNITS: CableType['unit'][] = ['ft', 'm'] @@ -12,7 +15,7 @@ function familyLabel(family: string, portTypes: PortType[]): string { return portTypes.find((pt) => pt.family === family)?.name ?? family } -function CableTypeForm({ +export function CableTypeForm({ initial, portTypes, onCancel, @@ -21,13 +24,7 @@ function CableTypeForm({ initial?: CableType portTypes: PortType[] onCancel: () => void - onSave: (fields: { - name: string - endAPortTypeId: string - endBPortTypeId: string - unit: CableType['unit'] - costPerUnit?: number - }) => void + onSave: (fields: CableTypeFormFields) => 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 @@ -132,8 +129,11 @@ export default function CableTypesPanel() { const catalog = useCatalogStore((s) => s.catalog) const addCustomCableType = useCatalogStore((s) => s.addCustomCableType) const updateCustomCableType = useCatalogStore((s) => s.updateCustomCableType) + const mySubmissions = useSubmissionStore((s) => s.mySubmissions) + const submitForReview = useSubmissionStore((s) => s.submitForReview) const [creating, setCreating] = useState(false) const [editingId, setEditingId] = useState(null) + const [suggestingId, setSuggestingId] = useState(null) const portTypes = useMemo( () => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)), @@ -146,55 +146,45 @@ export default function CableTypesPanel() { const customTypes = useMemo(() => allTypes.filter((ct) => ct.custom), [allTypes]) const builtInTypes = useMemo(() => allTypes.filter((ct) => !ct.custom), [allTypes]) + const pendingEntityIds = useMemo( + () => + new Set( + mySubmissions.filter((s) => s.entityType === 'cable_type' && s.status === 'pending').map((s) => s.entityId), + ), + [mySubmissions], + ) + const describeEnds = (ct: CableType) => { const a = familyLabel(ct.family, portTypes) const b = familyLabel(ct.family2 ?? ct.family, portTypes) return a === b ? a : `${a} ↔ ${b}` } - const handleCreate = (fields: { - name: string - endAPortTypeId: string - endBPortTypeId: string - unit: CableType['unit'] - costPerUnit?: number - }) => { - const endA = portTypes.find((pt) => pt.id === fields.endAPortTypeId) - const endB = portTypes.find((pt) => pt.id === fields.endBPortTypeId) - if (!endA || !endB) return - addCustomCableType({ - name: fields.name, - family: endA.family, - family2: endA.family === endB.family ? undefined : endB.family, - unit: fields.unit, - costPerUnit: fields.costPerUnit, - }) + const handleCreate = (fields: CableTypeFormFields) => { + const resolved = resolveCableTypeFields(fields, portTypes) + if (!resolved) return + addCustomCableType(resolved) setCreating(false) } - const handleUpdate = ( - id: string, - fields: { - name: string - endAPortTypeId: string - endBPortTypeId: string - unit: CableType['unit'] - costPerUnit?: number - }, - ) => { - const endA = portTypes.find((pt) => pt.id === fields.endAPortTypeId) - const endB = portTypes.find((pt) => pt.id === fields.endBPortTypeId) - if (!endA || !endB) return - updateCustomCableType(id, { - name: fields.name, - family: endA.family, - family2: endA.family === endB.family ? undefined : endB.family, - unit: fields.unit, - costPerUnit: fields.costPerUnit, - }) + const handleUpdate = (id: string, fields: CableTypeFormFields) => { + const resolved = resolveCableTypeFields(fields, portTypes) + if (!resolved) return + updateCustomCableType(id, resolved) setEditingId(null) } + const handleSubmitForPublic = (ct: CableType) => { + submitForReview('cable_type', ct.id, cableTypeToRow(ct)) + } + + const handleSuggestEdit = (id: string, fields: CableTypeFormFields) => { + const resolved = resolveCableTypeFields(fields, portTypes) + if (!resolved) return + submitForReview('cable_type', id, cableTypeToRow(resolved)) + setSuggestingId(null) + } + return (
@@ -240,12 +230,27 @@ export default function CableTypesPanel() { {typeof ct.costPerUnit === 'number' && ` · $${ct.costPerUnit.toFixed(2)}/${ct.unit}`}
- +
+ {pendingEntityIds.has(ct.id) ? ( + + Pending review + + ) : ( + + )} + +
), )} @@ -257,14 +262,41 @@ export default function CableTypesPanel() {

Built-in ({builtInTypes.length})

-
- {builtInTypes.map((ct) => ( -
- {ct.name} · {describeEnds(ct)} -
- ))} +
+ {builtInTypes.map((ct) => + suggestingId === ct.id ? ( +
+ setSuggestingId(null)} + onSave={(fields) => handleSuggestEdit(ct.id, fields)} + /> +
+ ) : ( +
+ + {ct.name} · {describeEnds(ct)} + + {pendingEntityIds.has(ct.id) ? ( + + Edit pending review + + ) : ( + + )} +
+ ), + )}
-

Built-in cable types can't be edited or removed.

+

+ Built-in cable types can't be edited directly — "Suggest edit" sends a proposed change to Admins for review. +

) diff --git a/src/components/palette/DevicePalette.tsx b/src/components/palette/DevicePalette.tsx index 275be69..8bbc7c8 100644 --- a/src/components/palette/DevicePalette.tsx +++ b/src/components/palette/DevicePalette.tsx @@ -1,13 +1,17 @@ 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) @@ -16,7 +20,9 @@ export default function DevicePalette() { const removeCustomDeviceTemplate = useCatalogStore((s) => s.removeCustomDeviceTemplate) const hidePublicDeviceTemplate = useCatalogStore((s) => s.hidePublicDeviceTemplate) const restorePublicDeviceTemplate = useCatalogStore((s) => s.restorePublicDeviceTemplate) - const [editorTarget, setEditorTarget] = useState<'new' | DeviceTemplate | null>(null) + const mySubmissions = useSubmissionStore((s) => s.mySubmissions) + const submitForReview = useSubmissionStore((s) => s.submitForReview) + const [editorTarget, setEditorTarget] = useState(null) const [portTypesOpen, setPortTypesOpen] = useState(false) const [hiddenListOpen, setHiddenListOpen] = useState(false) // Collapsed by default — expanding on demand is the whole point (keeps a @@ -81,6 +87,18 @@ export default function DevicePalette() { 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 (