- frontend-spec.md: rewrite CreateItemPane spec for dynamic form
rendering from form descriptor, replace CategoryPicker three-column
spec with multi-stage domain/subcategory picker, replace useCategories
hook with useFormDescriptor, update form sections to dynamic field
groups, mark hierarchical categories as implemented, remove
sourcing_link/standard_cost from item-level DB columns, update types
and implementation order
- SPECIFICATION.md: rename /api/schemas/{name}/properties endpoint to
/api/schemas/{name}/form
758 lines
35 KiB
Markdown
758 lines
35 KiB
Markdown
# Silo Frontend Specification
|
||
|
||
Current as of 2026-02-11. Documents the React + Vite + TypeScript frontend (migration from Go templates is complete).
|
||
|
||
## Overview
|
||
|
||
The Silo web UI has been migrated from server-rendered Go templates to a React single-page application. The Go templates (~7,000 lines across 7 files) have been removed. The Go API server serves JSON at `/api/*` and the React SPA at `/`.
|
||
|
||
**Stack**: React 19, React Router 7, Vite 6, TypeScript 5.7
|
||
**Theme**: Catppuccin Mocha (dark) via CSS custom properties
|
||
**Styling**: Inline React styles using `React.CSSProperties` — no CSS modules, no Tailwind, no styled-components
|
||
**State**: Local `useState` + custom hooks. No global state library (no Redux, Zustand, etc.)
|
||
**Dependencies**: Minimal — only `react`, `react-dom`, `react-router-dom`. No axios, no tanstack-query.
|
||
|
||
## Migration Status
|
||
|
||
| Phase | Issue | Title | Status |
|
||
|-------|-------|-------|--------|
|
||
| 1 | #7 | Scaffold React + Vite + TS, shared layout, auth, API client | Code complete |
|
||
| 2 | #8 | Migrate Items page with UI improvements | Code complete |
|
||
| 3 | #9 | Migrate Projects, Schemas, Settings, Login pages | Code complete |
|
||
| 4 | #10 | Remove Go templates, Docker integration, cleanup | Complete |
|
||
|
||
## Architecture
|
||
|
||
```
|
||
Browser
|
||
└── React SPA (served at /)
|
||
├── Vite dev server (development) → proxies /api/* to Go backend
|
||
└── Static files in web/dist/ (production) → served by Go binary
|
||
|
||
Go Server (silod)
|
||
├── /api/* JSON REST API
|
||
├── /login, /logout Session auth endpoints (form POST)
|
||
├── /auth/oidc OIDC redirect flow
|
||
└── /* React SPA (NotFound handler serves index.html for client-side routing)
|
||
```
|
||
|
||
### Auth Flow
|
||
|
||
1. React app loads, `AuthProvider` calls `GET /api/auth/me`
|
||
2. If 401 → render `LoginPage` (React form)
|
||
3. Login form POSTs `application/x-www-form-urlencoded` to `/login` (Go handler sets session cookie)
|
||
4. On success, `AuthProvider.refresh()` re-fetches `/api/auth/me`, user state populates, app renders
|
||
5. OIDC: link to `/auth/oidc` triggers Go-served redirect flow, callback sets session, user returns to app
|
||
6. API client auto-redirects to `/login` on any 401 response
|
||
|
||
### Public API Endpoint
|
||
|
||
`GET /api/auth/config` — returns `{ oidc_enabled: bool, local_enabled: bool }` so the login page can conditionally show the OIDC button without hardcoding.
|
||
|
||
## File Structure
|
||
|
||
```
|
||
web/
|
||
├── index.html
|
||
├── package.json
|
||
├── tsconfig.json
|
||
├── tsconfig.node.json
|
||
├── vite.config.ts
|
||
└── src/
|
||
├── main.tsx Entry point, renders AuthProvider + BrowserRouter + App
|
||
├── App.tsx Route definitions, auth guard
|
||
├── api/
|
||
│ ├── client.ts fetch wrapper: get, post, put, del + ApiError class
|
||
│ └── types.ts All TypeScript interfaces (272 lines)
|
||
├── context/
|
||
│ └── AuthContext.tsx AuthProvider with login/logout/refresh methods
|
||
├── hooks/
|
||
│ ├── useAuth.ts Context consumer hook
|
||
│ ├── useFormDescriptor.ts Fetches form descriptor from /api/schemas/{name}/form (replaces useCategories)
|
||
│ ├── useItems.ts Items fetching with search, filters, pagination, debounce
|
||
│ └── useLocalStorage.ts Typed localStorage persistence hook
|
||
├── styles/
|
||
│ ├── theme.css Catppuccin Mocha CSS custom properties
|
||
│ └── global.css Base element styles
|
||
├── components/
|
||
│ ├── AppShell.tsx Header nav + user info + <Outlet/>
|
||
│ ├── ContextMenu.tsx Reusable right-click positioned menu
|
||
│ └── items/ Items page components (16 files)
|
||
│ ├── ItemsToolbar.tsx Search, filters, layout toggle, action buttons
|
||
│ ├── ItemTable.tsx Sortable table, column config, compact rows
|
||
│ ├── ItemDetail.tsx 5-tab detail panel (Main, Properties, Revisions, BOM, Where Used)
|
||
│ ├── MainTab.tsx Metadata display, project tags editor, file info
|
||
│ ├── PropertiesTab.tsx Form/JSON dual-mode property editor
|
||
│ ├── RevisionsTab.tsx Revision list, compare diff, status, rollback
|
||
│ ├── BOMTab.tsx BOM table with inline CRUD, cost calculations
|
||
│ ├── WhereUsedTab.tsx Parent assemblies table
|
||
│ ├── SplitPanel.tsx Resizable horizontal/vertical layout container
|
||
│ ├── FooterStats.tsx Fixed bottom bar with item counts
|
||
│ ├── CreateItemPane.tsx In-pane create form with schema category properties
|
||
│ ├── EditItemPane.tsx In-pane edit form
|
||
│ ├── DeleteItemPane.tsx In-pane delete confirmation
|
||
│ └── ImportItemsPane.tsx CSV upload with dry-run/import flow
|
||
└── pages/
|
||
├── LoginPage.tsx Username/password form + OIDC button
|
||
├── ItemsPage.tsx Orchestrator: toolbar, split panel, table, detail/CRUD panes
|
||
├── ProjectsPage.tsx Project CRUD with sortable table, in-pane forms
|
||
├── SchemasPage.tsx Schema browser with collapsible segments, enum value CRUD
|
||
├── SettingsPage.tsx Account info, API token management
|
||
└── AuditPage.tsx Audit completeness (placeholder, expanded in Issue #5)
|
||
```
|
||
|
||
**Total**: ~40 source files, ~7,600 lines of TypeScript/TSX.
|
||
|
||
## Design System
|
||
|
||
### Theme
|
||
|
||
Catppuccin Mocha dark theme. All colors referenced via CSS custom properties:
|
||
|
||
| Token | Use |
|
||
|-------|-----|
|
||
| `--ctp-base` | Page background, input backgrounds |
|
||
| `--ctp-mantle` | Header background |
|
||
| `--ctp-surface0` | Card backgrounds, even table rows |
|
||
| `--ctp-surface1` | Borders, dividers, hover states |
|
||
| `--ctp-surface2` | Secondary button backgrounds |
|
||
| `--ctp-text` | Primary text |
|
||
| `--ctp-subtext0/1` | Secondary/muted text, labels |
|
||
| `--ctp-overlay0` | Placeholder text |
|
||
| `--ctp-mauve` | Brand accent, primary buttons, nav active |
|
||
| `--ctp-blue` | Editor role badge, edit headers |
|
||
| `--ctp-green` | Success banners, create headers |
|
||
| `--ctp-red` | Errors, delete actions, danger buttons |
|
||
| `--ctp-peach` | Part numbers, project codes, token prefixes |
|
||
| `--ctp-teal` | Viewer role badge |
|
||
| `--ctp-sapphire` | Links, collapsible toggles |
|
||
| `--ctp-crust` | Dark text on colored backgrounds |
|
||
|
||
### Typography
|
||
|
||
- Body: system font stack (Inter, -apple-system, etc.)
|
||
- Monospace: JetBrains Mono (part numbers, codes, tokens)
|
||
- Table cells: 0.85rem
|
||
- Labels: 0.85rem, weight 500
|
||
- Table headers: 0.8rem, uppercase, letter-spacing 0.05em
|
||
|
||
### Component Patterns
|
||
|
||
**Tables**: Inline styles, compact rows (28-32px), alternating `base`/`surface0` backgrounds, sortable headers with arrow indicators, right-click column config (Items page).
|
||
|
||
**Forms**: In-pane forms (Infor ERP-style) — not modal overlays. Create/Edit/Delete forms render in the detail pane area with a colored header bar (green=create, blue=edit, red=delete). Cancel returns to previous view.
|
||
|
||
**Cards**: `surface0` background, `0.75rem` border radius, `1.5rem` padding.
|
||
|
||
**Buttons**: Primary (`mauve` bg, `crust` text), secondary (`surface1` bg), danger (`red` bg or translucent red bg with red text).
|
||
|
||
**Errors**: Red text with translucent red background banner, `0.4rem` border radius.
|
||
|
||
**Role badges**: Colored pill badges — admin=mauve, editor=blue, viewer=teal.
|
||
|
||
## Page Specifications
|
||
|
||
### Items Page (completed in #8)
|
||
|
||
The most complex page. Master-detail layout with resizable split panel.
|
||
|
||
**Toolbar**: Debounced search (300ms) with scope toggle (All/PN/Description), type and project filter dropdowns, layout toggle (horizontal/vertical), export/import/create buttons.
|
||
|
||
**Table**: 7 configurable columns (part_number, item_type, description, revision, projects, created, actions). Visibility stored per layout mode in localStorage. Right-click header opens ContextMenu with checkboxes. Compact rows, zebra striping, click to select.
|
||
|
||
**Detail panel**: 5 tabs — Main (metadata + project tags + file info), Properties (form/JSON editor, save creates revision), Revisions (compare, status management, rollback), BOM (inline CRUD, cost calculations, CSV export), Where Used (parent assemblies).
|
||
|
||
**CRUD panes**: In-pane forms for Create (schema category properties, project tags), Edit (basic fields), Delete (confirmation), Import (CSV upload with dry-run).
|
||
|
||
**Footer**: Fixed 28px bottom bar showing Total | Parts | Assemblies | Documents counts, reactive to filters.
|
||
|
||
**State**: `PaneMode` discriminated union manages which pane is shown. `useItems` hook handles fetching, search, filters, pagination. `useLocalStorage` persists layout and column preferences.
|
||
|
||
### Projects Page (completed in #9)
|
||
|
||
Sortable table with columns: Code, Name, Description, Items (count fetched per project), Created, Actions.
|
||
|
||
**CRUD**: In-pane forms above the table. Create requires code (2-10 chars, auto-uppercase), name, description. Edit allows name and description changes. Delete shows confirmation with project code.
|
||
|
||
**Navigation**: Click project code navigates to Items page with `?project=CODE` filter.
|
||
|
||
**Permissions**: Create/Edit/Delete buttons only visible to editor/admin roles.
|
||
|
||
### Schemas Page (completed in #9)
|
||
|
||
Schema cards with collapsible segment details. Each schema shows name, description, format string, version, and example part numbers.
|
||
|
||
**Segments**: Expandable list showing segment name, type badge, description. Enum segments include a values table with code and description columns.
|
||
|
||
**Enum CRUD**: Inline table operations — add row at bottom, edit replaces the row, delete highlights the row with confirmation. All operations call `POST/PUT/DELETE /api/schemas/{name}/segments/{segment}/values/{code}`.
|
||
|
||
### Settings Page (completed in #9)
|
||
|
||
Two cards:
|
||
|
||
**Account**: Read-only grid showing username, display name, email, auth source, role (with colored badge). Data from `useAuth()` context.
|
||
|
||
**API Tokens**: Create form (name input + button), one-time token display in green banner with copy-to-clipboard, token list table (name, prefix, created, last used, expires, revoke). Revoke has inline confirm step. Uses `GET/POST/DELETE /api/auth/tokens`.
|
||
|
||
### Login Page (completed in #9)
|
||
|
||
Standalone centered card (no AppShell). Username/password form, OIDC button shown conditionally based on `GET /api/auth/config`. Error messages in red banner. Submit calls `AuthContext.login()` which POSTs form data to `/login` then re-fetches the user.
|
||
|
||
### Audit Page (placeholder)
|
||
|
||
Basic table showing audit completeness data from `GET /api/audit/completeness`. Will be expanded as part of Issue #5 (Component Audit UI with completeness scoring and inline editing).
|
||
|
||
## API Client
|
||
|
||
`web/src/api/client.ts` — thin wrapper around `fetch`:
|
||
|
||
- Always sends `credentials: 'include'` for session cookies
|
||
- Always sets `Content-Type: application/json`
|
||
- 401 responses redirect to `/login`
|
||
- Non-OK responses parsed as `{ error, message }` and thrown as `ApiError`
|
||
- 204 responses return `undefined`
|
||
- Exports: `get<T>()`, `post<T>()`, `put<T>()`, `del()`
|
||
|
||
## Type Definitions
|
||
|
||
`web/src/api/types.ts` — 272 lines covering all API response and request shapes:
|
||
|
||
**Core models**: User, Item, Project, Schema, SchemaSegment, Revision, BOMEntry
|
||
**Audit**: AuditFieldResult, AuditItemResult, AuditSummary, AuditCompletenessResponse
|
||
**Search**: FuzzyResult (extends Item with score)
|
||
**BOM**: WhereUsedEntry, AddBOMEntryRequest, UpdateBOMEntryRequest
|
||
**Items**: CreateItemRequest, UpdateItemRequest, CreateRevisionRequest
|
||
**Projects**: CreateProjectRequest, UpdateProjectRequest
|
||
**Schemas**: CreateSchemaValueRequest, UpdateSchemaValueRequest, PropertyDef, PropertySchema
|
||
**Auth**: AuthConfig, ApiToken, ApiTokenCreated
|
||
**Revisions**: RevisionComparison
|
||
**Import**: CSVImportResult, CSVImportError
|
||
**Errors**: ErrorResponse
|
||
|
||
## Completed Work
|
||
|
||
### Issue #10: Remove Go Templates + Docker Integration -- COMPLETE
|
||
|
||
Completed in commit `50923cf`. All Go templates deleted, `web.go` handler removed, SPA serves at `/` via `NotFound` handler with `index.html` fallback. `build/package/Dockerfile` added.
|
||
|
||
### Remaining Work
|
||
|
||
### Issue #5: Component Audit UI (future)
|
||
|
||
The Audit page will be expanded with completeness scoring, inline editing, tier filtering, and category breakdowns. This will be built natively in React using the patterns established in the migration.
|
||
|
||
## Development
|
||
|
||
```bash
|
||
# Install dependencies
|
||
cd web && npm install
|
||
|
||
# Dev server (proxies /api/* to Go backend on :8080)
|
||
npm run dev
|
||
|
||
# Type check
|
||
npx tsc --noEmit
|
||
|
||
# Production build
|
||
npm run build
|
||
```
|
||
|
||
Vite dev server runs on port 5173 with proxy config in `vite.config.ts` forwarding `/api/*`, `/login`, `/logout`, `/auth/*` to the Go backend.
|
||
|
||
## Conventions
|
||
|
||
- **No modals for CRUD** — use in-pane forms (Infor ERP-style pattern)
|
||
- **No shared component library extraction** until a pattern repeats 3+ times
|
||
- **Inline styles only** — all styling via `React.CSSProperties` objects, using Catppuccin CSS variables
|
||
- **No class components** — functional components with hooks only
|
||
- **Permission checks**: derive `isEditor` from `user.role` in each page, conditionally render write actions
|
||
- **Error handling**: try/catch with error state, display in red banners inline
|
||
- **Data fetching**: `useEffect` + API client on mount, loading/error/data states
|
||
- **Persistence**: `useLocalStorage` hook for user preferences (layout mode, column visibility)
|
||
|
||
## New Frontend Tasks
|
||
|
||
# CreateItemPane — Schema-Driven Dynamic Form
|
||
|
||
**Date**: 2026-02-10
|
||
**Scope**: `CreateItemPane.tsx` renders a dynamic form driven entirely by the form descriptor API (`GET /api/schemas/{name}/form`). All field groups, field types, widgets, and category-specific fields are defined in YAML and resolved server-side.
|
||
**Parent**: Items page (`ItemsPage.tsx`) — renders in the detail pane area per existing in-pane CRUD pattern.
|
||
|
||
---
|
||
|
||
## Layout
|
||
|
||
Single-column scrollable form with a green header bar. Field groups are rendered dynamically from the form descriptor. Category-specific field groups appear after global groups when a category is selected.
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────────────────┐
|
||
│ Header: "New Item" [green bar] Cancel │ Create │
|
||
├──────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ Category * [Domain buttons: F C R S E M T A P X] │
|
||
│ [Subcategory search + filtered list] │
|
||
│ │
|
||
│ ── Identity ────────────────────────────────────────────────────── │
|
||
│ [Type * (auto-derived from category)] [Description ] │
|
||
│ │
|
||
│ ── Sourcing ────────────────────────────────────────────────────── │
|
||
│ [Sourcing Type v] [Manufacturer] [MPN] [Supplier] [SPN] │
|
||
│ [Sourcing Link] │
|
||
│ │
|
||
│ ── Cost & Lead Time ────────────────────────────────────────────── │
|
||
│ [Standard Cost $] [Lead Time Days] [Min Order Qty] │
|
||
│ │
|
||
│ ── Status ──────────────────────────────────────────────────────── │
|
||
│ [Lifecycle Status v] [RoHS Compliant ☐] [Country of Origin] │
|
||
│ │
|
||
│ ── Details ─────────────────────────────────────────────────────── │
|
||
│ [Long Description ] │
|
||
│ [Projects: [tag][tag] type to search... ] │
|
||
│ [Notes ] │
|
||
│ │
|
||
│ ── Fastener Specifications (category-specific) ─────────────────── │
|
||
│ [Material] [Finish] [Thread Size] [Head Type] [Drive Type] ... │
|
||
│ │
|
||
└──────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
## Data Source — Form Descriptor API
|
||
|
||
All form structure is fetched from `GET /api/schemas/kindred-rd/form`, which returns:
|
||
|
||
- `category_picker`: Multi-stage picker config (domain → subcategory)
|
||
- `item_fields`: Definitions for item-level fields (description, item_type, sourcing_type, etc.)
|
||
- `field_groups`: Ordered groups with resolved field metadata (Identity, Sourcing, Cost, Status, Details)
|
||
- `category_field_groups`: Per-category-prefix groups (e.g., Fastener Specifications for `F` prefix)
|
||
- `field_overrides`: Widget hints (currency, url, select, checkbox)
|
||
|
||
The YAML schema (`schemas/kindred-rd.yaml`) is the single source of truth. Adding a new field or category in YAML propagates to all clients with no code changes.
|
||
|
||
## File Location
|
||
|
||
`web/src/components/items/CreateItemPane.tsx`
|
||
|
||
Supporting files:
|
||
|
||
| File | Purpose |
|
||
|------|---------|
|
||
| `web/src/components/items/CategoryPicker.tsx` | Multi-stage domain/subcategory selector |
|
||
| `web/src/components/items/FileDropZone.tsx` | Drag-and-drop file upload with MinIO presigned URLs |
|
||
| `web/src/components/items/TagInput.tsx` | Multi-select tag input for projects |
|
||
| `web/src/hooks/useFormDescriptor.ts` | Fetches and caches form descriptor from `/api/schemas/{name}/form` |
|
||
| `web/src/hooks/useFileUpload.ts` | Manages presigned URL upload flow |
|
||
|
||
## Component Breakdown
|
||
|
||
### CreateItemPane
|
||
|
||
Top-level orchestrator. Renders dynamic form from the form descriptor.
|
||
|
||
**Props** (unchanged interface):
|
||
|
||
```typescript
|
||
interface CreateItemPaneProps {
|
||
onCreated: (item: Item) => void;
|
||
onCancel: () => void;
|
||
}
|
||
```
|
||
|
||
**State**:
|
||
|
||
```typescript
|
||
const { descriptor, categories, loading } = useFormDescriptor();
|
||
const [category, setCategory] = useState(''); // selected category code, e.g. "F01"
|
||
const [fields, setFields] = useState<Record<string, string>>({}); // all field values keyed by name
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
```
|
||
|
||
A single `fields` record holds all form values (both item-level and property fields). The `ITEM_LEVEL_FIELDS` set (`description`, `item_type`, `sourcing_type`, `long_description`) determines which fields go into the top-level request vs. the `properties` map on submission.
|
||
|
||
**Auto-derivation**: When a category is selected, `item_type` is automatically set based on the `derived_from_category` mapping in the form descriptor (e.g., category prefix `A` → `assembly`, `T` → `tooling`, default → `part`).
|
||
|
||
**Dynamic rendering**: A `renderField()` function maps each field's `widget` type to the appropriate input:
|
||
|
||
| Widget | Rendered As |
|
||
|--------|-------------|
|
||
| `text` | `<input type="text">` |
|
||
| `number` | `<input type="number">` |
|
||
| `textarea` | `<textarea>` |
|
||
| `select` | `<select>` with `<option>` elements from `field.options` |
|
||
| `checkbox` | `<input type="checkbox">` |
|
||
| `currency` | `<input type="number">` with currency prefix (e.g., "$") |
|
||
| `url` | `<input type="url">` |
|
||
| `tag_input` | `TagInput` component with search endpoint |
|
||
|
||
**Submission flow**:
|
||
|
||
1. Validate required fields (category must be selected).
|
||
2. Split `fields` into item-level fields and properties using `ITEM_LEVEL_FIELDS`.
|
||
3. `POST /api/items` with `{ part_number: '', item_type, description, sourcing_type, long_description, category, properties: {...} }`.
|
||
4. Call `onCreated(item)`.
|
||
|
||
**Header bar**: Green (`--ctp-green` background, `--ctp-crust` text). "New Item" title on left, Cancel and Create Item buttons on right.
|
||
|
||
### CategoryPicker
|
||
|
||
Multi-stage category selector driven by the form descriptor's `category_picker.stages` config.
|
||
|
||
**Props**:
|
||
|
||
```typescript
|
||
interface CategoryPickerProps {
|
||
value: string; // selected category code, e.g. "F01"
|
||
onChange: (code: string) => void;
|
||
categories: Record<string, string>; // flat code → description map
|
||
stages?: CategoryPickerStage[]; // from form descriptor
|
||
}
|
||
```
|
||
|
||
**Rendering**: Two-stage selection:
|
||
|
||
1. **Domain row**: Horizontal row of buttons, one per domain from `stages[0].values` (F=Fasteners, C=Fluid Fittings, etc.). Selected domain has mauve highlight.
|
||
2. **Subcategory list**: Filtered list of categories matching the selected domain prefix. Includes a search input for filtering. Each row shows code and description.
|
||
|
||
If no `stages` prop is provided, falls back to a flat searchable list of all categories.
|
||
|
||
Below the picker, the selected category is shown as a breadcrumb: `Fasteners › F01 — Hex Cap Screw` in `--ctp-mauve`.
|
||
|
||
**Data source**: Categories come from `useFormDescriptor()` which derives them from the `category_picker` stages and `values_by_domain` in the form descriptor response.
|
||
|
||
### FileDropZone
|
||
|
||
Handles drag-and-drop and click-to-browse file uploads with MinIO presigned URL flow.
|
||
|
||
**Props**:
|
||
|
||
```typescript
|
||
interface FileDropZoneProps {
|
||
files: PendingAttachment[];
|
||
onFilesAdded: (files: PendingAttachment[]) => void;
|
||
onFileRemoved: (index: number) => void;
|
||
accept?: string; // e.g. '.FCStd,.step,.stl,.pdf,.png,.jpg'
|
||
}
|
||
|
||
interface PendingAttachment {
|
||
file: File;
|
||
objectKey: string; // MinIO key after upload
|
||
uploadProgress: number; // 0-100
|
||
uploadStatus: 'pending' | 'uploading' | 'complete' | 'error';
|
||
error?: string;
|
||
}
|
||
```
|
||
|
||
**Drop zone UI**: Dashed `2px` border using `--ctp-surface1`, `border-radius: 0.5rem`, centered content with a paperclip icon (Unicode 📎 or inline SVG), "Drop files here or **browse**" text, and accepted formats in `--ctp-overlay0` at 10px.
|
||
|
||
States:
|
||
- **Default**: dashed border `--ctp-surface1`
|
||
- **Drag over**: dashed border `--ctp-mauve`, background `rgba(203, 166, 247, 0.05)`
|
||
- **Uploading**: show progress per file in the file list
|
||
|
||
Clicking the zone opens a hidden `<input type="file" multiple>`.
|
||
|
||
**File list**: Rendered below the drop zone. Each file shows:
|
||
- Type icon: colored 28×28 rounded square. Color mapping: `.FCStd`/`.step`/`.stl` → `--ctp-blue` ("CAD"), `.pdf` → `--ctp-red` ("PDF"), `.png`/`.jpg` → `--ctp-green` ("IMG"), other → `--ctp-overlay1` ("FILE").
|
||
- File name (truncated with ellipsis).
|
||
- File size + type label in `--ctp-overlay0` at 10px.
|
||
- Upload progress bar (thin 2px bar under the file item, `--ctp-mauve` fill) when uploading.
|
||
- Remove button (`×`) on the right, `--ctp-overlay0` → `--ctp-red` on hover.
|
||
|
||
**Upload flow** (managed by `useFileUpload` hook):
|
||
|
||
1. On file selection/drop, immediately request a presigned upload URL: `POST /api/uploads/presign` with `{ filename, content_type, size }`.
|
||
2. Backend returns `{ object_key, upload_url, expires_at }`.
|
||
3. `PUT` the file directly to the presigned MinIO URL using `XMLHttpRequest` (for progress tracking).
|
||
4. On completion, update `PendingAttachment.uploadStatus` to `'complete'` and store the `object_key`.
|
||
5. The `object_key` is later sent to the item creation endpoint to associate the file.
|
||
|
||
If the presigned URL endpoint doesn't exist yet, see Backend Changes.
|
||
|
||
### TagInput
|
||
|
||
Reusable multi-select input for projects (and potentially other tag-like fields).
|
||
|
||
**Props**:
|
||
|
||
```typescript
|
||
interface TagInputProps {
|
||
value: string[]; // selected project IDs
|
||
onChange: (ids: string[]) => void;
|
||
placeholder?: string;
|
||
searchFn: (query: string) => Promise<{ id: string; label: string }[]>;
|
||
}
|
||
```
|
||
|
||
**Rendering**: Container styled like a form input (`--ctp-crust` bg, `--ctp-surface1` border, `border-radius: 0.4rem`). Inside:
|
||
- Selected tags as inline pills: `rgba(203, 166, 247, 0.15)` bg, `--ctp-mauve` text, 11px font, with `×` remove button.
|
||
- A bare `<input>` (no border/bg) that grows to fill remaining width, `min-width: 80px`.
|
||
|
||
**Behavior**: On typing, debounce 200ms, call `searchFn(query)`. Show a dropdown below the input with matching results. Click or Enter selects. Already-selected items are excluded from results. Escape or blur closes the dropdown.
|
||
|
||
The dropdown is an absolutely-positioned `<div>` below the input container, `--ctp-crust` background, `--ctp-surface1` border, `border-radius: 0.4rem`, `max-height: 160px`, `overflow-y: auto`. Each row is 28px, hover `--ctp-surface0`.
|
||
|
||
**For projects**: `searchFn` calls `GET /api/projects?q={query}` and maps to `{ id: project.id, label: project.code + ' — ' + project.name }`.
|
||
|
||
### useFormDescriptor Hook
|
||
|
||
```typescript
|
||
function useFormDescriptor(schemaName = "kindred-rd"): {
|
||
descriptor: FormDescriptor | null;
|
||
categories: Record<string, string>; // flat code → description map derived from descriptor
|
||
loading: boolean;
|
||
}
|
||
```
|
||
|
||
Fetches `GET /api/schemas/{name}/form` on mount. Caches the result in a module-level variable so repeated renders/mounts don't refetch. Derives a flat `categories` map from the `category_picker` stages and `values_by_domain` in the response. Replaces the old `useCategories` hook (deleted).
|
||
|
||
### useFileUpload Hook
|
||
|
||
```typescript
|
||
function useFileUpload(): {
|
||
upload: (file: File) => Promise<PendingAttachment>;
|
||
uploading: boolean;
|
||
}
|
||
```
|
||
|
||
Encapsulates the presigned URL flow. Returns a function that takes a `File`, gets a presigned URL, uploads via XHR with progress tracking, and returns the completed `PendingAttachment`. The component manages the array of attachments in its own state.
|
||
|
||
## Styling
|
||
|
||
All styling via inline `React.CSSProperties` objects, per project convention. Reference Catppuccin tokens through `var(--ctp-*)` strings. No CSS modules, no Tailwind, no class names.
|
||
|
||
Common style patterns to extract as `const` objects at the top of each file:
|
||
|
||
```typescript
|
||
const styles = {
|
||
container: {
|
||
display: 'grid',
|
||
gridTemplateColumns: '1fr 320px',
|
||
height: '100%',
|
||
overflow: 'hidden',
|
||
} as React.CSSProperties,
|
||
|
||
formArea: {
|
||
padding: '1.5rem 2rem',
|
||
overflowY: 'auto',
|
||
} as React.CSSProperties,
|
||
|
||
formGrid: {
|
||
display: 'grid',
|
||
gridTemplateColumns: '1fr 1fr',
|
||
gap: '1.25rem 1.5rem',
|
||
maxWidth: '800px',
|
||
} as React.CSSProperties,
|
||
|
||
sidebar: {
|
||
background: 'var(--ctp-mantle)',
|
||
borderLeft: '1px solid var(--ctp-surface0)',
|
||
display: 'flex',
|
||
flexDirection: 'column' as const,
|
||
overflowY: 'auto',
|
||
} as React.CSSProperties,
|
||
|
||
// ... etc
|
||
};
|
||
```
|
||
|
||
## Form Sections
|
||
|
||
Form sections are rendered dynamically from the `field_groups` array in the form descriptor. Each section header is a flex row containing a label (11px uppercase, `--ctp-overlay0`) and a `flex: 1` horizontal line (`1px solid --ctp-surface0`).
|
||
|
||
**Global field groups** (from `ui.field_groups` in YAML):
|
||
|
||
| Group Key | Label | Fields |
|
||
|-----------|-------|--------|
|
||
| identity | Identity | item_type, description |
|
||
| sourcing | Sourcing | sourcing_type, manufacturer, manufacturer_pn, supplier, supplier_pn, sourcing_link |
|
||
| cost | Cost & Lead Time | standard_cost, lead_time_days, minimum_order_qty |
|
||
| status | Status | lifecycle_status, rohs_compliant, country_of_origin |
|
||
| details | Details | long_description, projects, notes |
|
||
|
||
**Category-specific field groups** (from `ui.category_field_groups` in YAML, shown when a category is selected):
|
||
|
||
| Prefix | Group | Example Fields |
|
||
|--------|-------|----------------|
|
||
| F | Fastener Specifications | material, finish, thread_size, head_type, drive_type, ... |
|
||
| C | Fitting Specifications | material, connection_type, size_1, pressure_rating, ... |
|
||
| R | Motion Specifications | bearing_type, bore_diameter, load_rating, ... |
|
||
| ... | ... | (one group per category prefix, defined in YAML) |
|
||
|
||
Note: `sourcing_link` and `standard_cost` are revision properties (stored in the `properties` JSONB), not item-level DB columns. They were migrated from item-level fields in PR #1 (migration 013).
|
||
|
||
## Backend Changes
|
||
|
||
Items 1-5 below are implemented. Item 4 (hierarchical categories) is resolved by the form descriptor's multi-stage category picker.
|
||
|
||
### 1. Presigned Upload URL -- IMPLEMENTED
|
||
|
||
```
|
||
POST /api/uploads/presign
|
||
Request: { "filename": "bracket.FCStd", "content_type": "application/octet-stream", "size": 2400000 }
|
||
Response: { "object_key": "uploads/tmp/{uuid}/{filename}", "upload_url": "https://minio.../...", "expires_at": "2026-02-06T..." }
|
||
```
|
||
|
||
The Go handler generates a presigned PUT URL via the MinIO SDK. Objects are uploaded to a temporary prefix. On item creation, they're moved/linked to the item's permanent prefix.
|
||
|
||
### 2. File Association -- IMPLEMENTED
|
||
|
||
```
|
||
POST /api/items/{id}/files
|
||
Request: { "object_key": "uploads/tmp/{uuid}/bracket.FCStd", "filename": "bracket.FCStd", "content_type": "...", "size": 2400000 }
|
||
Response: { "file_id": "uuid", "filename": "...", "size": ..., "created_at": "..." }
|
||
```
|
||
|
||
Moves the object from the temp prefix to `items/{item_id}/files/{file_id}` and creates a row in a new `item_files` table.
|
||
|
||
### 3. Thumbnail -- IMPLEMENTED
|
||
|
||
```
|
||
PUT /api/items/{id}/thumbnail
|
||
Request: { "object_key": "uploads/tmp/{uuid}/thumb.png" }
|
||
Response: 204
|
||
```
|
||
|
||
Stores the thumbnail at `items/{item_id}/thumbnail.png` in MinIO. Updates `item.thumbnail_key` column.
|
||
|
||
### 4. Hierarchical Categories -- IMPLEMENTED (via Form Descriptor)
|
||
|
||
Resolved by the schema-driven form descriptor (`GET /api/schemas/{name}/form`). The YAML schema's `ui.category_picker` section defines multi-stage selection:
|
||
|
||
- **Stage 1 (domain)**: Groups categories by first character of category code (F=Fasteners, C=Fluid Fittings, etc.). Values defined in `ui.category_picker.stages[0].values`.
|
||
- **Stage 2 (subcategory)**: Auto-derived by the Go backend's `ValuesByDomain()` method, which groups the category enum values by their first character.
|
||
|
||
No separate `categories` table is needed — the existing schema enum values are the single source of truth. Adding a new category code to the YAML propagates to the picker automatically.
|
||
|
||
### 5. Database Schema Addition -- IMPLEMENTED
|
||
|
||
```sql
|
||
CREATE TABLE item_files (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||
filename TEXT NOT NULL,
|
||
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||
size BIGINT NOT NULL DEFAULT 0,
|
||
object_key TEXT NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE INDEX idx_item_files_item ON item_files(item_id);
|
||
|
||
ALTER TABLE items ADD COLUMN thumbnail_key TEXT;
|
||
ALTER TABLE items ADD COLUMN sourcing_type TEXT NOT NULL DEFAULT 'manufactured';
|
||
ALTER TABLE items ADD COLUMN unit_of_measure TEXT NOT NULL DEFAULT 'ea';
|
||
ALTER TABLE items ADD COLUMN long_description TEXT;
|
||
```
|
||
|
||
## Implementation Order
|
||
|
||
1. **[DONE] Deduplicate sourcing_link/standard_cost** — Migrated from item-level DB columns to revision properties (migration 013). Removed from Go structs, API types, frontend types.
|
||
2. **[DONE] Form descriptor API** — Added `ui` section to YAML, Go structs + validation, `GET /api/schemas/{name}/form` endpoint.
|
||
3. **[DONE] useFormDescriptor hook** — Replaces `useCategories`, fetches and caches form descriptor.
|
||
4. **[DONE] CategoryPicker rewrite** — Multi-stage domain/subcategory picker driven by form descriptor.
|
||
5. **[DONE] CreateItemPane rewrite** — Dynamic form rendering from field groups, widget-based field rendering.
|
||
6. **TagInput component** — reusable, no backend changes needed, uses existing projects API.
|
||
7. **FileDropZone + useFileUpload** — requires presigned URL backend endpoint (already implemented).
|
||
|
||
## Types Added
|
||
|
||
The following types were added to `web/src/api/types.ts` for the form descriptor system:
|
||
|
||
```typescript
|
||
// Form descriptor types (from GET /api/schemas/{name}/form)
|
||
interface FormFieldDescriptor {
|
||
name: string;
|
||
type: string;
|
||
widget: string;
|
||
label: string;
|
||
required?: boolean;
|
||
default?: string;
|
||
unit?: string;
|
||
description?: string;
|
||
options?: string[];
|
||
currency?: string;
|
||
derived_from_category?: Record<string, string>;
|
||
search_endpoint?: string;
|
||
}
|
||
|
||
interface FormFieldGroup {
|
||
key: string;
|
||
label: string;
|
||
order: number;
|
||
fields: FormFieldDescriptor[];
|
||
}
|
||
|
||
interface CategoryPickerStage {
|
||
name: string;
|
||
label: string;
|
||
values?: Record<string, string>;
|
||
values_by_domain?: Record<string, Record<string, string>>;
|
||
}
|
||
|
||
interface CategoryPickerDescriptor {
|
||
style: string;
|
||
stages: CategoryPickerStage[];
|
||
}
|
||
|
||
interface ItemFieldDef {
|
||
type: string;
|
||
widget: string;
|
||
label: string;
|
||
required?: boolean;
|
||
default?: string;
|
||
options?: string[];
|
||
derived_from_category?: Record<string, string>;
|
||
search_endpoint?: string;
|
||
}
|
||
|
||
interface FieldOverride {
|
||
widget?: string;
|
||
currency?: string;
|
||
options?: string[];
|
||
}
|
||
|
||
interface FormDescriptor {
|
||
schema_name: string;
|
||
format: string;
|
||
category_picker: CategoryPickerDescriptor;
|
||
item_fields: Record<string, ItemFieldDef>;
|
||
field_groups: FormFieldGroup[];
|
||
category_field_groups: Record<string, FormFieldGroup[]>;
|
||
field_overrides: Record<string, FieldOverride>;
|
||
}
|
||
|
||
// File uploads (unchanged)
|
||
interface PresignRequest {
|
||
filename: string;
|
||
content_type: string;
|
||
size: number;
|
||
}
|
||
|
||
interface PresignResponse {
|
||
object_key: string;
|
||
upload_url: string;
|
||
expires_at: string;
|
||
}
|
||
|
||
interface ItemFile {
|
||
id: string;
|
||
item_id: string;
|
||
filename: string;
|
||
content_type: string;
|
||
size: number;
|
||
object_key: string;
|
||
created_at: string;
|
||
}
|
||
|
||
// Pending upload (frontend only, not an API type)
|
||
interface PendingAttachment {
|
||
file: File;
|
||
objectKey: string;
|
||
uploadProgress: number;
|
||
uploadStatus: 'pending' | 'uploading' | 'complete' | 'error';
|
||
error?: string;
|
||
}
|
||
```
|
||
|
||
Note: `sourcing_link` and `standard_cost` have been removed from the `Item`, `CreateItemRequest`, and `UpdateItemRequest` interfaces — they are now stored as revision properties and rendered dynamically from the form descriptor.
|