Files
silo/web/src/components/items/CreateItemPane.tsx
forbes-0023 ffa01ebeb7 feat(api): direct multipart upload endpoints for filesystem backend
Add three new endpoints that bypass the MinIO presigned URL flow:
- POST /api/items/{pn}/files/upload — multipart file upload
- POST /api/items/{pn}/thumbnail/upload — multipart thumbnail upload
- GET /api/items/{pn}/files/{fileId}/download — stream file download

Rewrite frontend upload flow: files are held in browser memory on drop
and uploaded directly after item creation via multipart POST. The old
presign+associate endpoints remain for MinIO backward compatibility.

Closes #129
2026-02-17 13:04:44 -06:00

678 lines
18 KiB
TypeScript

import { useState, useCallback } from "react";
import { get, post } from "../../api/client";
import type {
Project,
FormFieldDescriptor,
FormFieldGroup,
} from "../../api/types";
import { TagInput, type TagOption } from "../TagInput";
import { CategoryPicker } from "./CategoryPicker";
import { FileDropZone } from "./FileDropZone";
import { useFormDescriptor } from "../../hooks/useFormDescriptor";
import {
useFileUpload,
type PendingAttachment,
} from "../../hooks/useFileUpload";
import { useAuth } from "../../hooks/useAuth";
// Item-level field names that are sent as top-level API fields, not properties.
const ITEM_LEVEL_FIELDS = new Set([
"item_type",
"description",
"sourcing_type",
"long_description",
"projects",
]);
interface CreateItemPaneProps {
onCreated: (partNumber: string) => void;
onCancel: () => void;
}
export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
const { user } = useAuth();
const { descriptor, categories } = useFormDescriptor();
const { upload } = useFileUpload();
// Single form state for all fields (item-level + properties).
const [category, setCategory] = useState("");
const [fields, setFields] = useState<Record<string, string>>({});
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
// Attachments.
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const [thumbnailFile, setThumbnailFile] = useState<PendingAttachment | null>(
null,
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const setField = (name: string, value: string) =>
setFields((prev) => ({ ...prev, [name]: value }));
const getField = (name: string) => fields[name] ?? "";
// Derive item_type from category using derived_from_category mapping
const deriveItemType = (cat: string): string => {
if (!cat || !descriptor?.item_fields?.item_type?.derived_from_category) {
return getField("item_type") || "part";
}
const mapping = descriptor.item_fields.item_type.derived_from_category;
const prefix = cat[0]!;
return mapping[prefix] ?? mapping["default"] ?? "part";
};
const handleCategoryChange = (cat: string) => {
setCategory(cat);
// Auto-derive item_type when category changes
if (descriptor?.item_fields?.item_type?.derived_from_category) {
const derived = cat
? (descriptor.item_fields.item_type.derived_from_category[cat[0]!] ??
descriptor.item_fields.item_type.derived_from_category["default"] ??
"part")
: "part";
setField("item_type", derived);
}
};
const searchProjects = useCallback(
async (query: string): Promise<TagOption[]> => {
const all = await get<Project[]>("/api/projects");
const q = query.toLowerCase();
return all
.filter(
(p) =>
!q ||
p.code.toLowerCase().includes(q) ||
(p.name ?? "").toLowerCase().includes(q),
)
.map((p) => ({
id: p.code,
label: p.code + (p.name ? " \u2014 " + p.name : ""),
}));
},
[],
);
const handleFilesAdded = useCallback((files: PendingAttachment[]) => {
setAttachments((prev) => [...prev, ...files]);
}, []);
const handleFileRemoved = useCallback((index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index));
}, []);
const handleThumbnailSelect = useCallback(() => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".png,.jpg,.jpeg,.webp";
input.onchange = () => {
const file = input.files?.[0];
if (!file) return;
setThumbnailFile({
file,
objectKey: "",
uploadProgress: 0,
uploadStatus: "pending",
});
};
input.click();
}, []);
const handleSubmit = async () => {
if (!category) {
setError("Category is required");
return;
}
setSaving(true);
setError(null);
// Split fields into item-level and properties
const properties: Record<string, unknown> = {};
for (const [k, v] of Object.entries(fields)) {
if (!v) continue;
if (ITEM_LEVEL_FIELDS.has(k)) continue; // handled separately
// Coerce type from descriptor
const fieldDef = findFieldDef(k);
if (fieldDef?.type === "number") properties[k] = Number(v);
else if (fieldDef?.type === "boolean") properties[k] = v === "true";
else properties[k] = v;
}
try {
const result = await post<{ part_number: string }>("/api/items", {
schema: "kindred-rd",
category,
description: getField("description") || undefined,
item_type: deriveItemType(category),
projects: selectedProjects.length > 0 ? selectedProjects : undefined,
properties: Object.keys(properties).length > 0 ? properties : undefined,
sourcing_type: getField("sourcing_type") || undefined,
long_description: getField("long_description") || undefined,
});
const pn = result.part_number;
const encodedPN = encodeURIComponent(pn);
// Upload attachments via direct multipart POST.
for (const att of attachments) {
try {
await upload(att.file, `/api/items/${encodedPN}/files/upload`);
} catch {
// File upload failure is non-blocking.
}
}
// Upload thumbnail via direct multipart POST.
if (thumbnailFile) {
try {
await upload(
thumbnailFile.file,
`/api/items/${encodedPN}/thumbnail/upload`,
);
} catch {
// Thumbnail failure is non-blocking.
}
}
onCreated(pn);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to create item");
} finally {
setSaving(false);
}
};
// Find field definition from descriptor (global groups + category groups).
function findFieldDef(name: string): FormFieldDescriptor | undefined {
if (descriptor?.field_groups) {
for (const group of descriptor.field_groups) {
const f = group.fields.find((fd) => fd.name === name);
if (f) return f;
}
}
if (descriptor?.category_field_groups && category) {
const prefix = category[0]!;
const catGroups = descriptor.category_field_groups[prefix];
if (catGroups) {
for (const group of catGroups) {
const f = group.fields.find((fd) => fd.name === name);
if (f) return f;
}
}
}
return undefined;
}
// Get category-specific field groups for the selected category.
const catFieldGroups: FormFieldGroup[] =
category && descriptor?.category_field_groups
? (descriptor.category_field_groups[category[0]!] ?? [])
: [];
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{/* Header */}
<div style={headerStyle}>
<span
style={{
color: "var(--ctp-green)",
fontWeight: 600,
fontSize: "var(--font-body)",
}}
>
New Item
</span>
<span style={{ flex: 1 }} />
<button
onClick={() => void handleSubmit()}
disabled={saving}
style={{
...actionBtnStyle,
backgroundColor: "var(--ctp-green)",
opacity: saving ? 0.6 : 1,
}}
>
{saving ? "Creating..." : "Create"}
</button>
<button onClick={onCancel} style={cancelBtnStyle}>
Cancel
</button>
</div>
{/* Two-column body */}
<div
style={{
flex: 1,
display: "grid",
gridTemplateColumns: "1fr 280px",
overflow: "hidden",
}}
>
{/* Left: form */}
<div style={{ overflow: "auto", padding: "0.75rem" }}>
{error && <div style={errorStyle}>{error}</div>}
{/* Category picker */}
<SectionHeader>Category *</SectionHeader>
<CategoryPicker
value={category}
onChange={handleCategoryChange}
categories={categories}
stages={descriptor?.category_picker?.stages}
/>
{/* Dynamic field groups from descriptor */}
{descriptor?.field_groups?.map((group) => (
<div key={group.key}>
<SectionHeader>{group.label}</SectionHeader>
<div style={fieldGridStyle}>
{group.fields.map((field) =>
renderField(
field,
getField(field.name),
(v) => setField(field.name, v),
selectedProjects,
setSelectedProjects,
searchProjects,
),
)}
</div>
</div>
))}
{/* Category-specific field groups */}
{catFieldGroups.map((group) => (
<div key={group.key}>
<SectionHeader>{group.label}</SectionHeader>
<div style={fieldGridStyle}>
{group.fields.map((field) =>
renderField(
field,
getField(field.name),
(v) => setField(field.name, v),
selectedProjects,
setSelectedProjects,
searchProjects,
),
)}
</div>
</div>
))}
</div>
{/* Right: sidebar */}
<div
style={{
borderLeft: "1px solid var(--ctp-surface1)",
overflow: "auto",
padding: "0.75rem",
display: "flex",
flexDirection: "column",
gap: "0.75rem",
}}
>
{/* Metadata */}
<SidebarSection title="Metadata">
<MetaRow label="Revision" value="A" />
<MetaRow
label="Created By"
value={user?.display_name ?? user?.username ?? "---"}
/>
</SidebarSection>
{/* Attachments */}
<SidebarSection title="Attachments">
<FileDropZone
files={attachments}
onFilesAdded={handleFilesAdded}
onFileRemoved={handleFileRemoved}
accept=".FCStd,.step,.stl,.pdf,.png,.jpg,.dxf"
/>
</SidebarSection>
{/* Thumbnail */}
<SidebarSection title="Thumbnail">
<div
onClick={handleThumbnailSelect}
style={{
aspectRatio: "4/3",
borderRadius: "0.5rem",
border: "1px dashed var(--ctp-surface1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
overflow: "hidden",
backgroundColor: "var(--ctp-mantle)",
}}
>
{thumbnailFile ? (
<img
src={URL.createObjectURL(thumbnailFile.file)}
alt="Thumbnail preview"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<span
style={{
fontSize: "var(--font-table)",
color: "var(--ctp-subtext0)",
}}
>
Click to select
</span>
)}
</div>
</SidebarSection>
</div>
</div>
</div>
);
}
// --- Field renderer ---
function renderField(
field: FormFieldDescriptor,
value: string,
onChange: (v: string) => void,
selectedProjects: string[],
setSelectedProjects: (v: string[]) => void,
searchProjects: (q: string) => Promise<{ id: string; label: string }[]>,
) {
const widget =
field.widget ?? (field.type === "boolean" ? "checkbox" : "text");
// Projects field gets special tag_input treatment
if (widget === "tag_input") {
return (
<div key={field.name} style={{ gridColumn: "1 / -1" }}>
<FormGroup label={field.label}>
<TagInput
value={selectedProjects}
onChange={setSelectedProjects}
placeholder="Search projects\u2026"
searchFn={searchProjects}
/>
</FormGroup>
</div>
);
}
if (widget === "textarea") {
return (
<div key={field.name} style={{ gridColumn: "1 / -1" }}>
<FormGroup label={field.label}>
<textarea
className="silo-input"
value={value}
onChange={(e) => onChange(e.target.value)}
style={{ ...inputStyle, minHeight: 60, resize: "vertical" }}
placeholder={field.description ?? ""}
/>
</FormGroup>
</div>
);
}
if (widget === "select" && field.options) {
return (
<FormGroup key={field.name} label={field.label}>
<select
className="silo-input"
value={value || (field.default != null ? String(field.default) : "")}
onChange={(e) => onChange(e.target.value)}
style={inputStyle}
>
{!field.required && <option value="">---</option>}
{field.options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</FormGroup>
);
}
if (widget === "checkbox") {
return (
<FormGroup key={field.name} label={field.label}>
<select
className="silo-input"
value={value}
onChange={(e) => onChange(e.target.value)}
style={inputStyle}
>
<option value="">---</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</FormGroup>
);
}
if (widget === "currency") {
return (
<FormGroup
key={field.name}
label={`${field.label}${field.currency ? ` (${field.currency})` : ""}`}
>
<input
className="silo-input"
type="number"
step="0.01"
value={value}
onChange={(e) => onChange(e.target.value)}
style={inputStyle}
placeholder="0.00"
/>
</FormGroup>
);
}
if (widget === "url") {
return (
<div key={field.name} style={{ gridColumn: "1 / -1" }}>
<FormGroup label={field.label}>
<input
className="silo-input"
type="url"
value={value}
onChange={(e) => onChange(e.target.value)}
style={inputStyle}
placeholder="https://..."
/>
</FormGroup>
</div>
);
}
// Default: text or number input
const inputType = field.type === "number" ? "number" : "text";
const placeholder = field.unit
? `${field.description ?? ""} (${field.unit})`
: (field.description ?? "");
return (
<FormGroup key={field.name} label={field.label}>
<input
className="silo-input"
type={inputType}
value={value}
onChange={(e) => onChange(e.target.value)}
style={inputStyle}
placeholder={placeholder}
/>
</FormGroup>
);
}
// --- Sub-components ---
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
margin: "0.75rem 0 0.5rem",
}}
>
<span
style={{
fontSize: "var(--font-sm)",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--ctp-subtext0)",
whiteSpace: "nowrap",
}}
>
{children}
</span>
<div
style={{
flex: 1,
height: 1,
backgroundColor: "var(--ctp-surface1)",
}}
/>
</div>
);
}
function SidebarSection({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div
style={{
borderBottom: "1px solid var(--ctp-surface0)",
paddingBottom: "0.75rem",
}}
>
<div
style={{
fontSize: "var(--font-sm)",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--ctp-subtext0)",
marginBottom: "0.5rem",
}}
>
{title}
</div>
{children}
</div>
);
}
function MetaRow({ label, value }: { label: string; value: string }) {
return (
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "var(--font-table)",
padding: "0.25rem 0",
}}
>
<span style={{ color: "var(--ctp-subtext0)" }}>{label}</span>
<span style={{ color: "var(--ctp-text)" }}>{value}</span>
</div>
);
}
function FormGroup({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div style={{ marginBottom: "0.5rem" }}>
<label
style={{
display: "block",
fontSize: "0.75rem",
color: "var(--ctp-subtext0)",
marginBottom: "0.25rem",
}}
>
{label}
</label>
{children}
</div>
);
}
// --- Styles ---
const headerStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "0.5rem 0.75rem",
borderBottom: "1px solid var(--ctp-surface1)",
backgroundColor: "var(--ctp-mantle)",
flexShrink: 0,
};
const actionBtnStyle: React.CSSProperties = {
padding: "0.25rem 0.75rem",
fontSize: "0.75rem",
fontWeight: 500,
border: "none",
borderRadius: "0.25rem",
color: "var(--ctp-crust)",
cursor: "pointer",
};
const cancelBtnStyle: React.CSSProperties = {
background: "none",
border: "none",
cursor: "pointer",
color: "var(--ctp-subtext1)",
fontSize: "0.75rem",
fontWeight: 500,
padding: "0.25rem 0.5rem",
borderRadius: "0.25rem",
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "0.25rem 0.5rem",
fontSize: "var(--font-body)",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.25rem",
color: "var(--ctp-text)",
boxSizing: "border-box",
};
const fieldGridStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0 1rem",
};
const errorStyle: React.CSSProperties = {
color: "var(--ctp-red)",
backgroundColor: "rgba(243,139,168,0.1)",
padding: "0.5rem",
borderRadius: "0.25rem",
marginBottom: "0.5rem",
fontSize: "var(--font-body)",
};