feat(frontend): add field picker + numerical bucket UI in classify phase

Replaces "+ New category" with a Note/Numerical type-aware picker, extends
CategoryBucket to render type badges, bulk-add, and custom-bucket remove controls,
and deletes the addCategory() back-compat shim from Task 7.
This commit is contained in:
Experiments DB Dev
2026-05-01 08:01:41 -04:00
parent ef17ee0642
commit 39c01c567c
+69 -20
View File
@@ -57,7 +57,11 @@ function NoteChip({ value, category, colors, selected, onSelect, draggable, onDr
);
}
function CategoryBucket({ name, notes, colors, isOver, onDrop, onDragOver, onDragLeave, onRemoveNote, onClickAssign }) {
function CategoryBucket({
name, type, notes, colors, isOver, isCustom, unassignedCount,
onDrop, onDragOver, onDragLeave, onRemoveNote, onClickAssign,
onBulkAddUnassigned, onRemoveBucket,
}) {
return (
<div
onDragOver={(e) => { e.preventDefault(); onDragOver(); }}
@@ -65,11 +69,35 @@ function CategoryBucket({ name, notes, colors, isOver, onDrop, onDragOver, onDra
onDrop={(e) => { e.preventDefault(); onDrop(); }}
onClick={onClickAssign}
className={`
rounded-lg border-2 border-dashed p-3 min-h-[80px] transition-all cursor-pointer
relative group rounded-lg border-2 border-dashed p-3 min-h-[80px] transition-all cursor-pointer
${isOver ? `${colors.border} ${colors.bg} scale-[1.02]` : 'border-gray-200 hover:border-gray-300'}
`}
>
<p className={`text-xs font-semibold uppercase tracking-wide mb-2 ${colors.text}`}>{name}</p>
<div className="flex items-center justify-between mb-2">
<p className={`text-xs font-semibold uppercase tracking-wide ${colors.text}`}>
{name}
{type === 'numerical' && (
<span className="ml-2 text-[9px] font-bold uppercase bg-white/60 px-1 py-0.5 rounded border border-current/20">
numerical
</span>
)}
</p>
{isCustom && (
<button type="button"
onClick={(e) => { e.stopPropagation(); onRemoveBucket(); }}
className="text-[10px] text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
title="Remove this field"></button>
)}
</div>
{type === 'numerical' && unassignedCount > 0 && (
<button type="button"
onClick={(e) => { e.stopPropagation(); onBulkAddUnassigned(); }}
className={`text-[10px] mb-2 px-2 py-0.5 rounded border ${colors.border} ${colors.text} hover:bg-white transition-colors`}>
+ Add all {unassignedCount} unassigned
</button>
)}
<div className="flex flex-wrap gap-1.5 min-h-[28px]">
{notes.length === 0 && (
<span className="text-xs text-gray-400 italic">Drop here</span>
@@ -116,6 +144,7 @@ export default function CsvAnalysis({ dailyStatusId, animal, onSaved, onSummaryP
const [draggedNote, setDraggedNote] = useState(null);
const [overBucket, setOverBucket] = useState(null);
const [newCatName, setNewCatName] = useState('');
const [newCatType, setNewCatType] = useState('note');
const [showNewCat, setShowNewCat] = useState(false);
// Derived from buckets (recomputed every render — cheap)
@@ -211,13 +240,6 @@ export default function CsvAnalysis({ dailyStatusId, animal, onSaved, onSummaryP
setBuckets((prev) => [...prev, { name: trimmed, type, notes: [] }]);
}
// Back-compat shim so the existing "+ New category" UI keeps working until Task 9 swaps the picker
function addCategory() {
addBucket(newCatName, 'note');
setNewCatName('');
setShowNewCat(false);
}
const unassignedNotes = uniqueNotes.filter(n => !classifications[n]);
const allAssigned = uniqueNotes.length > 0 && unassignedNotes.length === 0;
@@ -432,9 +454,12 @@ export default function CsvAnalysis({ dailyStatusId, animal, onSaved, onSummaryP
<div className="grid grid-cols-3 gap-3">
{buckets.map((b) => {
const colors = getCategoryColors(b.name, customCategories);
const isCustom = !DEFAULT_BUCKET_NAMES.has(b.name);
return (
<CategoryBucket key={b.name} name={b.name} notes={b.notes} colors={colors}
<CategoryBucket key={b.name} name={b.name} type={b.type} notes={b.notes} colors={colors}
isOver={overBucket === b.name}
isCustom={isCustom}
unassignedCount={unassignedNotes.length}
onDragOver={() => setOverBucket(b.name)}
onDragLeave={() => setOverBucket(null)}
onDrop={() => {
@@ -442,26 +467,50 @@ export default function CsvAnalysis({ dailyStatusId, animal, onSaved, onSummaryP
}}
onClickAssign={() => { if (selectedNote) assign(selectedNote, b.name); }}
onRemoveNote={(note) => unassign(note)}
onBulkAddUnassigned={() => {
for (const n of unassignedNotes) assign(n, b.name);
}}
onRemoveBucket={() => setBuckets((prev) => prev.filter((x) => x.name !== b.name))}
/>
);
})}
</div>
{/* Add new category */}
<div className="flex items-center gap-2">
{/* Add new bucket — note or numerical */}
<div className="flex items-center gap-2 flex-wrap">
{showNewCat ? (
<>
<input autoFocus value={newCatName} onChange={(e) => setNewCatName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addCategory(); if (e.key === 'Escape') setShowNewCat(false); }}
placeholder="Category name…"
className="text-sm border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-40" />
<Button size="sm" type="button" onClick={addCategory}>Add</Button>
<button type="button" onClick={() => setShowNewCat(false)} className="text-xs text-gray-400 hover:text-gray-600">Cancel</button>
<input
autoFocus
value={newCatName}
onChange={(e) => setNewCatName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Escape') setShowNewCat(false); }}
placeholder="Field name…"
className="text-sm border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-40"
/>
<label className="text-xs text-gray-600 inline-flex items-center gap-1">
<input type="radio" name="newBucketType" value="note"
checked={newCatType === 'note'}
onChange={() => setNewCatType('note')} /> Note
</label>
<label className="text-xs text-gray-600 inline-flex items-center gap-1">
<input type="radio" name="newBucketType" value="numerical"
checked={newCatType === 'numerical'}
onChange={() => setNewCatType('numerical')} /> Numerical
</label>
<Button size="sm" type="button" onClick={() => {
addBucket(newCatName, newCatType);
setNewCatName('');
setNewCatType('note');
setShowNewCat(false);
}}>Add</Button>
<button type="button" onClick={() => setShowNewCat(false)}
className="text-xs text-gray-400 hover:text-gray-600">Cancel</button>
</>
) : (
<button type="button" onClick={() => setShowNewCat(true)}
className="text-xs text-indigo-500 hover:text-indigo-700 border border-dashed border-indigo-300 rounded-full px-3 py-0.5 hover:border-indigo-500 transition-colors">
+ New category
+ Add field
</button>
)}
</div>