diff --git a/client/src/api/items.ts b/client/src/api/items.ts index 196cd7c..8fcb021 100644 --- a/client/src/api/items.ts +++ b/client/src/api/items.ts @@ -83,6 +83,13 @@ interface SingleItemResponse { data: Item; } +export interface ImportedItemData { + manufacturer: string | null; + model: string | null; + note: string | null; + attributes: Record; +} + export const createItem = async ( projectId: string, payload: CreateItemPayload @@ -105,3 +112,22 @@ export const createItem = async ( return response.data; }; + +interface ImportItemResponse { + data: ImportedItemData; +} + +export const importItemFromUrl = async ( + projectId: string, + url: string +): Promise => { + const response = await apiFetch( + `/projects/${projectId}/items/import`, + { + method: 'POST', + body: { url } + } + ); + + return response.data; +}; diff --git a/client/src/components/item-form-modal.tsx b/client/src/components/item-form-modal.tsx index a212810..564885f 100644 --- a/client/src/components/item-form-modal.tsx +++ b/client/src/components/item-form-modal.tsx @@ -1,10 +1,5 @@ import { FormEvent, useEffect, useState } from 'react'; -import type { CreateItemPayload, ItemStatus } from '../api/items'; - -const itemStatusOptions: ItemStatus[] = ['active', 'rejected']; - -const isPlainObject = (value: unknown): value is Record => - Boolean(value) && typeof value === 'object' && !Array.isArray(value); +import type { CreateItemPayload, ImportedItemData } from '../api/items'; export interface ItemFormModalProps { isOpen: boolean; @@ -15,8 +10,36 @@ export interface ItemFormModalProps { onSubmit: (payload: CreateItemPayload) => Promise; isSubmitting: boolean; projectAttributes: string[]; + initialData: ImportedItemData | null; + isInitialDataLoading: boolean; + initialDataError: string | null; } +interface AttributeEntry { + id: string; + key: string; + value: string; +} + +const createUniqueId = () => Math.random().toString(36).slice(2); + +const stringifyAttributeValue = (value: unknown): string => { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +}; + export function ItemFormModal({ isOpen, mode, @@ -25,29 +48,67 @@ export function ItemFormModal({ onSuccess, onSubmit, isSubmitting, - projectAttributes + projectAttributes, + initialData, + isInitialDataLoading, + initialDataError }: ItemFormModalProps) { const [manufacturer, setManufacturer] = useState(''); const [model, setModel] = useState(''); - const [status, setStatus] = useState('active'); const [note, setNote] = useState(''); const [sourceUrl, setSourceUrl] = useState(''); - const [attributesJson, setAttributesJson] = useState('{}'); + const [attributeEntries, setAttributeEntries] = useState([]); const [error, setError] = useState(null); + const isFormDisabled = isSubmitting || isInitialDataLoading; useEffect(() => { if (!isOpen) { return; } - setManufacturer(''); - setModel(''); - setStatus('active'); - setNote(''); - setAttributesJson('{}'); setError(null); setSourceUrl(initialUrl ?? ''); - }, [isOpen, initialUrl, mode]); + setManufacturer(initialData?.manufacturer ?? ''); + setModel(initialData?.model ?? ''); + setNote(initialData?.note ?? ''); + + const trackedAttributeSet = new Set( + projectAttributes.map((attribute) => attribute.trim()).filter((attribute) => attribute.length) + ); + + const trackedEntries: AttributeEntry[] = []; + const additionalEntries: AttributeEntry[] = []; + + if (initialData?.attributes) { + Object.entries(initialData.attributes).forEach(([rawKey, value]) => { + const key = rawKey.trim(); + if (!key.length) { + return; + } + const entry = { + id: createUniqueId(), + key, + value: stringifyAttributeValue(value) + }; + if (trackedAttributeSet.has(key)) { + trackedEntries.push(entry); + trackedAttributeSet.delete(key); + } else { + additionalEntries.push(entry); + } + }); + } + + trackedAttributeSet.forEach((key) => { + trackedEntries.push({ + id: createUniqueId(), + key, + value: '' + }); + }); + + setAttributeEntries([...trackedEntries, ...additionalEntries]); + }, [isOpen, initialUrl, initialData, projectAttributes]); useEffect(() => { if (!isOpen) { @@ -68,6 +129,9 @@ export function ItemFormModal({ const handleSubmit = async (event: FormEvent) => { event.preventDefault(); + if (isFormDisabled) { + return; + } setError(null); if (!model.trim()) { @@ -75,30 +139,33 @@ export function ItemFormModal({ return; } - let attributes: Record = {}; - const trimmedAttributes = attributesJson.trim(); + const attributesPayload: Record = {}; + const seenAttributeKeys = new Set(); - if (trimmedAttributes) { - try { - const parsed = JSON.parse(trimmedAttributes); - if (!isPlainObject(parsed)) { - setError('Attributes must be a JSON object.'); - return; - } - attributes = parsed; - } catch (parseError) { - setError(`Attributes JSON is invalid: ${(parseError as Error).message}`); + for (const attribute of attributeEntries) { + const key = attribute.key.trim(); + if (!key) { + setError('Attribute name cannot be empty.'); return; } + + const normalizedKey = key.toLowerCase(); + if (seenAttributeKeys.has(normalizedKey)) { + setError(`Attribute name "${key}" is already in use.`); + return; + } + + seenAttributeKeys.add(normalizedKey); + attributesPayload[key] = attribute.value.trim() ? attribute.value.trim() : null; } try { await onSubmit({ manufacturer: manufacturer.trim() ? manufacturer.trim() : null, model: model.trim(), - status, + status: 'active', note: note.trim() ? note.trim() : null, - attributes, + attributes: attributesPayload, sourceUrl: sourceUrl.trim() ? sourceUrl.trim() : null }); onSuccess(); @@ -109,7 +176,7 @@ export function ItemFormModal({ return (
{ @@ -119,7 +186,7 @@ export function ItemFormModal({ }} >
event.stopPropagation()} >