feat(KB-074): add quick task creation with QuickEntryBox and NewTaskModal
- Add QuickEntryBox component for inline quick task entry in columns - Add NewTaskModal component with full-featured task creation UI - Integrate quick create flow through Column → Board → App component hierarchy - Add comprehensive tests for both new components and updated existing tests - Add CSS styles for modal, form inputs, and quick entry UI - Include changeset for @dustinbyrne/kb package release
This commit is contained in:
@@ -13,12 +13,13 @@ import { ToastContainer } from "./components/ToastContainer";
|
||||
import { GitHubImportModal } from "./components/GitHubImportModal";
|
||||
import { GitManagerModal } from "./components/GitManagerModal";
|
||||
import { UsageIndicator } from "./components/UsageIndicator";
|
||||
import { NewTaskModal } from "./components/NewTaskModal";
|
||||
import { useTasks } from "./hooks/useTasks";
|
||||
import { ToastProvider, useToast } from "./hooks/useToast";
|
||||
import { useTheme } from "./hooks/useTheme";
|
||||
|
||||
function AppInner() {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newTaskModalOpen, setNewTaskModalOpen] = useState(false);
|
||||
const [isPlanningOpen, setIsPlanningOpen] = useState(false);
|
||||
const [detailTask, setDetailTask] = useState<TaskDetail | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
@@ -86,13 +87,19 @@ function AppInner() {
|
||||
setView(newView);
|
||||
}, []);
|
||||
|
||||
const handleCreateOpen = useCallback(() => setIsCreating(true), []);
|
||||
const handleCancelCreate = useCallback(() => setIsCreating(false), []);
|
||||
const handleNewTaskOpen = useCallback(() => setNewTaskModalOpen(true), []);
|
||||
const handleNewTaskClose = useCallback(() => setNewTaskModalOpen(false), []);
|
||||
|
||||
const handleCreateTask = useCallback(
|
||||
const handleQuickCreate = useCallback(
|
||||
async (description: string): Promise<void> => {
|
||||
await createTask({ description, column: "triage" });
|
||||
},
|
||||
[createTask],
|
||||
);
|
||||
|
||||
const handleModalCreate = useCallback(
|
||||
async (input: TaskCreateInput): Promise<Task> => {
|
||||
const task = await createTask({ ...input, column: "triage" });
|
||||
setIsCreating(false);
|
||||
return task;
|
||||
},
|
||||
[createTask],
|
||||
@@ -180,10 +187,8 @@ function AppInner() {
|
||||
onMoveTask={moveTask}
|
||||
onOpenDetail={handleDetailOpen}
|
||||
addToast={addToast}
|
||||
isCreating={isCreating}
|
||||
onCancelCreate={handleCancelCreate}
|
||||
onCreateTask={handleCreateTask}
|
||||
onNewTask={handleCreateOpen}
|
||||
onQuickCreate={handleQuickCreate}
|
||||
onNewTask={handleNewTaskOpen}
|
||||
autoMerge={autoMerge}
|
||||
onToggleAutoMerge={handleToggleAutoMerge}
|
||||
globalPaused={globalPaused}
|
||||
@@ -198,10 +203,7 @@ function AppInner() {
|
||||
onOpenDetail={handleDetailOpen}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
isCreating={isCreating}
|
||||
onCancelCreate={handleCancelCreate}
|
||||
onCreateTask={handleCreateTask}
|
||||
onNewTask={handleCreateOpen}
|
||||
onNewTask={handleNewTaskOpen}
|
||||
/>
|
||||
)}
|
||||
{detailTask && (
|
||||
@@ -253,6 +255,13 @@ function AppInner() {
|
||||
isOpen={usageOpen}
|
||||
onClose={handleCloseUsage}
|
||||
/>
|
||||
<NewTaskModal
|
||||
isOpen={newTaskModalOpen}
|
||||
onClose={handleNewTaskClose}
|
||||
tasks={tasks}
|
||||
onCreateTask={handleModalCreate}
|
||||
addToast={addToast}
|
||||
/>
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,9 +10,7 @@ interface BoardProps {
|
||||
onMoveTask: (id: string, column: ColumnType) => Promise<Task>;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
isCreating: boolean;
|
||||
onCancelCreate: () => void;
|
||||
onCreateTask: (input: TaskCreateInput) => Promise<Task>;
|
||||
onQuickCreate?: (description: string) => Promise<void>;
|
||||
onNewTask: () => void;
|
||||
autoMerge: boolean;
|
||||
onToggleAutoMerge: () => void;
|
||||
@@ -25,7 +23,7 @@ interface BoardProps {
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
}
|
||||
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask }: BoardProps) {
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask }: BoardProps) {
|
||||
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||
|
||||
return (
|
||||
@@ -55,7 +53,7 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
|
||||
onUpdateTask={onUpdateTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
{...(col === "triage" ? { isCreating, onCancelCreate, onCreateTask, onNewTask } : {})}
|
||||
{...(col === "triage" ? { onQuickCreate, onNewTask } : {})}
|
||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: () => setArchivedCollapsed(!archivedCollapsed) } : {})}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Task, TaskDetail, TaskCreateInput, Column as ColumnType } from "@k
|
||||
import { COLUMN_LABELS, COLUMN_DESCRIPTIONS } from "@kb/core";
|
||||
import { TaskCard } from "./TaskCard";
|
||||
import { WorktreeGroup } from "./WorktreeGroup";
|
||||
import { InlineCreateCard } from "./InlineCreateCard";
|
||||
import { QuickEntryBox } from "./QuickEntryBox";
|
||||
import { groupByWorktree } from "../utils/worktreeGrouping";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
@@ -17,9 +17,7 @@ interface ColumnProps {
|
||||
onMoveTask: (id: string, column: ColumnType) => Promise<Task>;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
isCreating?: boolean;
|
||||
onCancelCreate?: () => void;
|
||||
onCreateTask?: (input: TaskCreateInput) => Promise<Task>;
|
||||
onQuickCreate?: (description: string) => Promise<void>;
|
||||
onNewTask?: () => void;
|
||||
autoMerge?: boolean;
|
||||
onToggleAutoMerge?: () => void;
|
||||
@@ -34,7 +32,7 @@ interface ColumnProps {
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, collapsed, onToggleCollapse }: ColumnProps) {
|
||||
export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, collapsed, onToggleCollapse }: ColumnProps) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const countFlashing = useFlashOnIncrease(tasks.length);
|
||||
|
||||
@@ -112,13 +110,8 @@ export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onO
|
||||
{!isCollapsed && <p className="column-desc">{COLUMN_DESCRIPTIONS[column]}</p>}
|
||||
{!isCollapsed && (
|
||||
<div className="column-body">
|
||||
{column === "triage" && isCreating && onCancelCreate && onCreateTask && (
|
||||
<InlineCreateCard
|
||||
tasks={allTasks}
|
||||
onSubmit={onCreateTask}
|
||||
onCancel={onCancelCreate}
|
||||
addToast={addToast}
|
||||
/>
|
||||
{column === "triage" && onQuickCreate && (
|
||||
<QuickEntryBox onCreate={onQuickCreate} addToast={addToast} />
|
||||
)}
|
||||
{column === "in-progress" ? (
|
||||
(() => {
|
||||
|
||||
741
packages/dashboard/app/components/NewTaskModal.tsx
Normal file
741
packages/dashboard/app/components/NewTaskModal.tsx
Normal file
@@ -0,0 +1,741 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import type { Task, TaskCreateInput } from "@kb/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { uploadAttachment, fetchModels, updateTask } from "../api";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { filterModels } from "../utils/modelFilter";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
|
||||
interface PendingImage {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
}
|
||||
|
||||
interface NewTaskModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tasks: Task[]; // for dependency selection
|
||||
onCreateTask: (input: TaskCreateInput) => Promise<Task>;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified ModelCombobox for the New Task modal.
|
||||
* Reuses the same interaction pattern as ModelSelectorTab.
|
||||
*/
|
||||
function ModelCombobox({
|
||||
value,
|
||||
onChange,
|
||||
models,
|
||||
disabled = false,
|
||||
placeholder = "Select a model…",
|
||||
label,
|
||||
id,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
models: ModelInfo[];
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
label: string;
|
||||
id: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [localFilter, setLocalFilter] = useState("");
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filteredModels = filterModels(models, localFilter);
|
||||
|
||||
const modelsByProvider = filteredModels.reduce<Record<string, ModelInfo[]>>((acc, m) => {
|
||||
(acc[m.provider] ??= []).push(m);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const optionsList = [
|
||||
{ type: "default" as const, value: "", label: "Use default" },
|
||||
...Object.entries(modelsByProvider).flatMap(([provider, providerModels]) => [
|
||||
{ type: "provider" as const, value: `__group_${provider}`, label: provider, provider },
|
||||
...providerModels.map((m) => ({
|
||||
type: "model" as const,
|
||||
value: `${m.provider}/${m.id}`,
|
||||
label: m.name,
|
||||
provider: m.provider
|
||||
})),
|
||||
]),
|
||||
];
|
||||
|
||||
const selectedDisplayText = !value
|
||||
? "Use default"
|
||||
: (() => {
|
||||
const slashIdx = value.indexOf("/");
|
||||
if (slashIdx === -1) return value;
|
||||
const provider = value.slice(0, slashIdx);
|
||||
const modelId = value.slice(slashIdx + 1);
|
||||
const model = models.find((m) => m.provider === provider && m.id === modelId);
|
||||
return model?.name || value;
|
||||
})();
|
||||
|
||||
const currentValueIndex = optionsList.findIndex((opt) => opt.value === value);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const selectableIndex = optionsList.findIndex((opt, idx) =>
|
||||
idx >= (currentValueIndex >= 0 ? currentValueIndex : 0) && opt.type !== "provider"
|
||||
);
|
||||
setHighlightedIndex(selectableIndex >= 0 ? selectableIndex : 0);
|
||||
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [isOpen, optionsList, currentValueIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setLocalFilter("");
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
let nextIndex = highlightedIndex;
|
||||
for (let i = 1; i <= optionsList.length; i++) {
|
||||
const idx = (highlightedIndex + i) % optionsList.length;
|
||||
if (optionsList[idx]?.type !== "provider") {
|
||||
nextIndex = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setHighlightedIndex(nextIndex);
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
if (isOpen) {
|
||||
let prevIndex = highlightedIndex;
|
||||
for (let i = 1; i <= optionsList.length; i++) {
|
||||
const idx = (highlightedIndex - i + optionsList.length) % optionsList.length;
|
||||
if (optionsList[idx]?.type !== "provider") {
|
||||
prevIndex = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setHighlightedIndex(prevIndex);
|
||||
}
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (isOpen) {
|
||||
const option = optionsList[highlightedIndex];
|
||||
if (option && option.type !== "provider") {
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
setLocalFilter("");
|
||||
}
|
||||
} else {
|
||||
setIsOpen(true);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setLocalFilter("");
|
||||
break;
|
||||
case "Tab":
|
||||
if (isOpen) {
|
||||
setIsOpen(false);
|
||||
setLocalFilter("");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [isOpen, highlightedIndex, optionsList, onChange]);
|
||||
|
||||
const handleSelect = useCallback((optionValue: string) => {
|
||||
onChange(optionValue);
|
||||
setIsOpen(false);
|
||||
setLocalFilter("");
|
||||
}, [onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && listRef.current) {
|
||||
const highlightedEl = listRef.current.querySelector(`[data-index="${highlightedIndex}"]`);
|
||||
if (highlightedEl && typeof highlightedEl.scrollIntoView === "function") {
|
||||
highlightedEl.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
}, [highlightedIndex, isOpen]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="model-combobox" onKeyDown={handleKeyDown}>
|
||||
<button
|
||||
type="button"
|
||||
id={id}
|
||||
className="model-combobox-trigger"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
aria-label={label}
|
||||
>
|
||||
<span className="model-combobox-trigger-text">{selectedDisplayText}</span>
|
||||
<span className="model-combobox-trigger-arrow">▼</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="model-combobox-dropdown" role="listbox">
|
||||
<div className="model-combobox-search-wrapper">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="model-combobox-search"
|
||||
placeholder="Filter models…"
|
||||
value={localFilter}
|
||||
onChange={(e) => setLocalFilter(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{localFilter && (
|
||||
<button
|
||||
type="button"
|
||||
className="model-combobox-clear"
|
||||
onClick={() => {
|
||||
setLocalFilter("");
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
aria-label="Clear filter"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="model-combobox-results-count">
|
||||
{filteredModels.length} model{filteredModels.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="model-combobox-list">
|
||||
<div
|
||||
data-index={0}
|
||||
className={`model-combobox-option ${highlightedIndex === 0 ? "model-combobox-option--highlighted" : ""} ${value === "" ? "model-combobox-option--selected" : ""}`}
|
||||
onClick={() => handleSelect("")}
|
||||
onMouseEnter={() => setHighlightedIndex(0)}
|
||||
role="option"
|
||||
aria-selected={value === ""}
|
||||
>
|
||||
<span className="model-combobox-option-text model-combobox-option-text--default">Use default</span>
|
||||
</div>
|
||||
|
||||
{Object.entries(modelsByProvider).map(([provider, providerModels]) => {
|
||||
const groupStartIndex = optionsList.findIndex((opt) => opt.value === `__group_${provider}`);
|
||||
|
||||
return (
|
||||
<div key={provider} className="model-combobox-group">
|
||||
<div
|
||||
className="model-combobox-optgroup"
|
||||
data-index={groupStartIndex}
|
||||
>
|
||||
{provider}
|
||||
</div>
|
||||
{providerModels.map((m) => {
|
||||
const optionValue = `${m.provider}/${m.id}`;
|
||||
const optionIndex = optionsList.findIndex((opt) => opt.value === optionValue);
|
||||
const isHighlighted = highlightedIndex === optionIndex;
|
||||
const isSelected = value === optionValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={optionValue}
|
||||
data-index={optionIndex}
|
||||
className={`model-combobox-option ${isHighlighted ? "model-combobox-option--highlighted" : ""} ${isSelected ? "model-combobox-option--selected" : ""}`}
|
||||
onClick={() => handleSelect(optionValue)}
|
||||
onMouseEnter={() => setHighlightedIndex(optionIndex)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
<span className="model-combobox-option-text">{m.name}</span>
|
||||
<span className="model-combobox-option-id">{m.id}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredModels.length === 0 && localFilter && (
|
||||
<div className="model-combobox-no-results">
|
||||
No models match '{localFilter}'
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast }: NewTaskModalProps) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [dependencies, setDependencies] = useState<string[]>([]);
|
||||
const [showDepDropdown, setShowDepDropdown] = useState(false);
|
||||
const [depSearch, setDepSearch] = useState("");
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [executorModel, setExecutorModel] = useState("");
|
||||
const [validatorModel, setValidatorModel] = useState("");
|
||||
const [enablePlanningMode, setEnablePlanningMode] = useState(false);
|
||||
const [hasDirtyState, setHasDirtyState] = useState(false);
|
||||
|
||||
const depDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load available models when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setModelsLoading(true);
|
||||
fetchModels()
|
||||
.then((models) => setAvailableModels(models))
|
||||
.catch(() => {/* silently fail - models just won't be available */})
|
||||
.finally(() => setModelsLoading(false));
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Track dirty state
|
||||
useEffect(() => {
|
||||
const isDirty =
|
||||
title.trim() !== "" ||
|
||||
description.trim() !== "" ||
|
||||
dependencies.length > 0 ||
|
||||
pendingImages.length > 0 ||
|
||||
executorModel !== "" ||
|
||||
validatorModel !== "" ||
|
||||
enablePlanningMode;
|
||||
setHasDirtyState(isDirty);
|
||||
}, [title, description, dependencies, pendingImages, executorModel, validatorModel, enablePlanningMode]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
if (!showDepDropdown) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (depDropdownRef.current && !depDropdownRef.current.contains(e.target as Node)) {
|
||||
setShowDepDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [showDepDropdown]);
|
||||
|
||||
// Handle paste for images
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.type.startsWith("image/")) {
|
||||
const file = item.getAsFile();
|
||||
if (file && ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||
e.preventDefault();
|
||||
setPendingImages((prev) => [
|
||||
...prev,
|
||||
{ file, previewUrl: URL.createObjectURL(file) },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle file drop for images
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||
setPendingImages((prev) => [
|
||||
...prev,
|
||||
{ file, previewUrl: URL.createObjectURL(file) },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeImage = useCallback((index: number) => {
|
||||
setPendingImages((prev) => {
|
||||
const removed = prev[index];
|
||||
if (removed) URL.revokeObjectURL(removed.previewUrl);
|
||||
return prev.filter((_, i) => i !== index);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleDep = useCallback((id: string) => {
|
||||
setDependencies((prev) =>
|
||||
prev.includes(id) ? prev.filter((d) => d !== id) : [...prev, id],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const truncate = (s: string, len: number) =>
|
||||
s.length > len ? s.slice(0, len) + "…" : s;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (hasDirtyState) {
|
||||
if (!confirm("You have unsaved changes. Discard them?")) return;
|
||||
}
|
||||
// Clean up object URLs
|
||||
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||||
setPendingImages([]);
|
||||
// Reset form
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setDependencies([]);
|
||||
setExecutorModel("");
|
||||
setValidatorModel("");
|
||||
setEnablePlanningMode(false);
|
||||
setHasDirtyState(false);
|
||||
onClose();
|
||||
}, [hasDirtyState, onClose, pendingImages]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const trimmedDesc = description.trim();
|
||||
if (!trimmedDesc || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Create the base task
|
||||
const task = await onCreateTask({
|
||||
title: title.trim() || undefined,
|
||||
description: trimmedDesc,
|
||||
column: "triage",
|
||||
dependencies: dependencies.length ? dependencies : undefined,
|
||||
});
|
||||
|
||||
// Upload pending images as attachments
|
||||
if (pendingImages.length > 0) {
|
||||
const failures: string[] = [];
|
||||
for (const img of pendingImages) {
|
||||
try {
|
||||
await uploadAttachment(task.id, img.file);
|
||||
} catch {
|
||||
failures.push(img.file.name);
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
addToast(`Failed to upload: ${failures.join(", ")}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Update task with model settings if specified
|
||||
const executorSlashIdx = executorModel.indexOf("/");
|
||||
const validatorSlashIdx = validatorModel.indexOf("/");
|
||||
|
||||
if (executorModel || validatorModel || enablePlanningMode) {
|
||||
const updates: Parameters<typeof updateTask>[1] = {};
|
||||
|
||||
if (executorModel && executorSlashIdx !== -1) {
|
||||
updates.modelProvider = executorModel.slice(0, executorSlashIdx);
|
||||
updates.modelId = executorModel.slice(executorSlashIdx + 1);
|
||||
}
|
||||
|
||||
if (validatorModel && validatorSlashIdx !== -1) {
|
||||
updates.validatorModelProvider = validatorModel.slice(0, validatorSlashIdx);
|
||||
updates.validatorModelId = validatorModel.slice(validatorSlashIdx + 1);
|
||||
}
|
||||
|
||||
// Note: enablePlanningMode would need backend support
|
||||
// TODO: Add backend support for per-task planning mode
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await updateTask(task.id, updates);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||||
setPendingImages([]);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setDependencies([]);
|
||||
setExecutorModel("");
|
||||
setValidatorModel("");
|
||||
setEnablePlanningMode(false);
|
||||
|
||||
addToast(`Created ${task.id}`, "success");
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to create task", "error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [description, title, dependencies, pendingImages, executorModel, validatorModel, enablePlanningMode, isSubmitting, onCreateTask, addToast, onClose]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !showDepDropdown) {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
}
|
||||
}, [handleClose, showDepDropdown]);
|
||||
|
||||
// Auto-resize textarea
|
||||
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setDescription(e.target.value);
|
||||
const el = e.target;
|
||||
el.style.height = "auto";
|
||||
el.style.height = el.scrollHeight + "px";
|
||||
}, []);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const availableDeps = tasks
|
||||
.filter((t) => !dependencies.includes(t.id))
|
||||
.sort((a, b) => {
|
||||
const cmp = b.createdAt.localeCompare(a.createdAt);
|
||||
if (cmp !== 0) return cmp;
|
||||
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
|
||||
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
|
||||
return bNum - aNum;
|
||||
});
|
||||
|
||||
const filteredDeps = depSearch
|
||||
? availableDeps.filter((t) =>
|
||||
t.id.toLowerCase().includes(depSearch.toLowerCase()) ||
|
||||
(t.title && t.title.toLowerCase().includes(depSearch.toLowerCase())) ||
|
||||
(t.description && t.description.toLowerCase().includes(depSearch.toLowerCase()))
|
||||
)
|
||||
: availableDeps;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleClose} onKeyDown={handleKeyDown}>
|
||||
<div
|
||||
className="modal modal-lg new-task-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onPaste={handlePaste}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h3>New Task</h3>
|
||||
<button className="modal-close" onClick={handleClose} disabled={isSubmitting}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{/* Title field */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-task-title">
|
||||
Title <span className="optional">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="new-task-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Brief title for the task"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description field */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-task-description">Description</label>
|
||||
<textarea
|
||||
ref={descTextareaRef}
|
||||
id="new-task-description"
|
||||
value={description}
|
||||
onChange={handleDescriptionChange}
|
||||
placeholder="What needs to be done?"
|
||||
rows={3}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
<div className="form-group">
|
||||
<label>Dependencies</label>
|
||||
<div className="dep-trigger-wrap" ref={depDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm dep-trigger"
|
||||
onClick={() => setShowDepDropdown((v) => !v)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{dependencies.length > 0 ? `${dependencies.length} selected` : "Add dependencies"}
|
||||
</button>
|
||||
{showDepDropdown && (
|
||||
<div className="dep-dropdown">
|
||||
<input
|
||||
className="dep-dropdown-search"
|
||||
placeholder="Search tasks…"
|
||||
autoFocus
|
||||
value={depSearch}
|
||||
onChange={(e) => setDepSearch(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{filteredDeps.length === 0 ? (
|
||||
<div className="dep-dropdown-empty">No available tasks</div>
|
||||
) : (
|
||||
filteredDeps.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`dep-dropdown-item${dependencies.includes(t.id) ? " selected" : ""}`}
|
||||
onClick={() => toggleDep(t.id)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<span className="dep-dropdown-id">{t.id}</span>
|
||||
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 30)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{dependencies.length > 0 && (
|
||||
<div className="selected-deps">
|
||||
{dependencies.map((depId) => (
|
||||
<span key={depId} className="dep-chip">
|
||||
{depId}
|
||||
<button
|
||||
type="button"
|
||||
className="dep-chip-remove"
|
||||
onClick={() => toggleDep(depId)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<div className="form-group">
|
||||
<label>Model Configuration</label>
|
||||
{modelsLoading ? (
|
||||
<div className="model-selector-loading">Loading models…</div>
|
||||
) : availableModels.length === 0 ? (
|
||||
<small>No models available. Configure authentication in Settings.</small>
|
||||
) : (
|
||||
<>
|
||||
<div className="model-select-row">
|
||||
<label htmlFor="executor-model" className="model-select-label">Executor</label>
|
||||
<ModelCombobox
|
||||
id="executor-model"
|
||||
label="Executor Model"
|
||||
value={executorModel}
|
||||
onChange={setExecutorModel}
|
||||
models={availableModels}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="model-select-row">
|
||||
<label htmlFor="validator-model" className="model-select-label">Validator</label>
|
||||
<ModelCombobox
|
||||
id="validator-model"
|
||||
label="Validator Model"
|
||||
value={validatorModel}
|
||||
onChange={setValidatorModel}
|
||||
models={availableModels}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Planning Mode Toggle */}
|
||||
<div className="form-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enablePlanningMode}
|
||||
onChange={(e) => setEnablePlanningMode(e.target.checked)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
Enable planning mode
|
||||
</label>
|
||||
<small>AI will ask clarifying questions before creating the task specification</small>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
<div className="form-group">
|
||||
<label>Attachments</label>
|
||||
{pendingImages.length > 0 && (
|
||||
<div className="inline-create-previews">
|
||||
{pendingImages.map((img, i) => (
|
||||
<div key={img.previewUrl} className="inline-create-preview">
|
||||
<img src={img.previewUrl} alt={img.file.name} />
|
||||
<button
|
||||
type="button"
|
||||
className="inline-create-preview-remove"
|
||||
onClick={() => removeImage(i)}
|
||||
disabled={isSubmitting}
|
||||
title="Remove image"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setPendingImages((prev) => [
|
||||
...prev,
|
||||
{ file, previewUrl: URL.createObjectURL(file) },
|
||||
]);
|
||||
e.target.value = "";
|
||||
}
|
||||
}}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Attach Screenshot
|
||||
</button>
|
||||
<small>You can also paste images or drag & drop</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-sm" onClick={handleClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!description.trim() || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Creating..." : "Create Task"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
packages/dashboard/app/components/QuickEntryBox.tsx
Normal file
64
packages/dashboard/app/components/QuickEntryBox.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
interface QuickEntryBoxProps {
|
||||
onCreate: (description: string) => Promise<void>;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export function QuickEntryBox({ onCreate, addToast }: QuickEntryBoxProps) {
|
||||
const [description, setDescription] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onCreate(trimmed);
|
||||
// Clear input and keep focus for rapid entry
|
||||
setDescription("");
|
||||
// Focus stays on input for next entry
|
||||
inputRef.current?.focus();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to create task", "error");
|
||||
// Keep input content on failure so user can retry
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [description, isSubmitting, onCreate, addToast]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (description.trim()) {
|
||||
// Clear non-empty input on Escape
|
||||
setDescription("");
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSubmit, description],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="quick-entry-box" data-testid="quick-entry-box">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="quick-entry-input"
|
||||
placeholder={isSubmitting ? "Creating..." : "Add a task..."}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isSubmitting}
|
||||
data-testid="quick-entry-input"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,9 +21,7 @@ function renderBoard() {
|
||||
onMoveTask={noopAsync}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
isCreating={false}
|
||||
onCancelCreate={noop}
|
||||
onCreateTask={noopAsync}
|
||||
onQuickCreate={noopAsync}
|
||||
onNewTask={noop}
|
||||
autoMerge={true}
|
||||
onToggleAutoMerge={noop}
|
||||
|
||||
@@ -10,8 +10,8 @@ vi.mock("../TaskCard", () => ({
|
||||
vi.mock("../WorktreeGroup", () => ({
|
||||
WorktreeGroup: () => <div />,
|
||||
}));
|
||||
vi.mock("../InlineCreateCard", () => ({
|
||||
InlineCreateCard: () => <div />,
|
||||
vi.mock("../QuickEntryBox", () => ({
|
||||
QuickEntryBox: () => <div data-testid="quick-entry-box" />,
|
||||
}));
|
||||
vi.mock("lucide-react", () => ({
|
||||
Link: () => null,
|
||||
@@ -75,3 +75,23 @@ describe("Column count-flash", () => {
|
||||
expect(badge.className).not.toContain("count-flash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Column QuickEntryBox", () => {
|
||||
it("renders QuickEntryBox in triage column when onQuickCreate is provided", () => {
|
||||
const tasks = [makeTask("KB-001")];
|
||||
render(<Column {...defaultProps} tasks={tasks} onQuickCreate={vi.fn()} />);
|
||||
expect(screen.getByTestId("quick-entry-box")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render QuickEntryBox in triage column when onQuickCreate is not provided", () => {
|
||||
const tasks = [makeTask("KB-001")];
|
||||
render(<Column {...defaultProps} tasks={tasks} />);
|
||||
expect(screen.queryByTestId("quick-entry-box")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render QuickEntryBox in non-triage columns", () => {
|
||||
const tasks = [makeTask("KB-001")];
|
||||
render(<Column {...defaultProps} tasks={tasks} column="todo" onQuickCreate={vi.fn()} />);
|
||||
expect(screen.queryByTestId("quick-entry-box")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { NewTaskModal } from "../NewTaskModal";
|
||||
import type { Task, Column } from "@kb/core";
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
uploadAttachment: vi.fn().mockResolvedValue({}),
|
||||
fetchModels: vi.fn().mockResolvedValue([
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
||||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||||
]),
|
||||
updateTask: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
function makeTask(id: string): Task {
|
||||
return {
|
||||
id,
|
||||
title: `Task ${id}`,
|
||||
description: `Description for ${id}`,
|
||||
column: "todo" as Column,
|
||||
status: undefined as any,
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
dependencies: [],
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function renderNewTaskModal(props = {}) {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
tasks: [] as Task[],
|
||||
onCreateTask: vi.fn().mockResolvedValue({ id: "KB-001" }),
|
||||
addToast: vi.fn(),
|
||||
};
|
||||
const mergedProps = { ...defaultProps, ...props };
|
||||
const result = render(<NewTaskModal {...mergedProps} />);
|
||||
return { ...result, props: mergedProps };
|
||||
}
|
||||
|
||||
describe("NewTaskModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not render when isOpen is false", () => {
|
||||
renderNewTaskModal({ isOpen: false });
|
||||
expect(screen.queryByText("New Task")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders all form fields when open", () => {
|
||||
renderNewTaskModal();
|
||||
|
||||
expect(screen.getByText("New Task")).toBeTruthy();
|
||||
expect(screen.getByLabelText(/Title/i)).toBeTruthy();
|
||||
expect(screen.getByLabelText(/Description/i)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Add dependencies" })).toBeTruthy();
|
||||
expect(screen.getByText(/Model Configuration/i)).toBeTruthy();
|
||||
expect(screen.getByLabelText(/Enable planning mode/i)).toBeTruthy();
|
||||
expect(screen.getByText(/Attachments/i)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Create Task" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("creates task with all provided data on submit", async () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
const titleInput = screen.getByLabelText(/Title/i);
|
||||
const descTextarea = screen.getByLabelText(/Description/i);
|
||||
|
||||
fireEvent.change(titleInput, { target: { value: "My Task Title" } });
|
||||
fireEvent.change(descTextarea, { target: { value: "My task description" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "My Task Title",
|
||||
description: "My task description",
|
||||
column: "triage",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onClose after successful creation", async () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
const descTextarea = screen.getByLabelText(/Description/i);
|
||||
fireEvent.change(descTextarea, { target: { value: "Test description" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast on creation failure", async () => {
|
||||
const { props } = renderNewTaskModal({
|
||||
onCreateTask: vi.fn().mockRejectedValue(new Error("Creation failed")),
|
||||
});
|
||||
|
||||
const descTextarea = screen.getByLabelText(/Description/i);
|
||||
fireEvent.change(descTextarea, { target: { value: "Test description" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.addToast).toHaveBeenCalledWith("Creation failed", "error");
|
||||
});
|
||||
});
|
||||
|
||||
it("disables create button when description is empty", () => {
|
||||
renderNewTaskModal();
|
||||
|
||||
const createButton = screen.getByRole("button", { name: "Create Task" });
|
||||
expect(createButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables create button when description is not empty", () => {
|
||||
renderNewTaskModal();
|
||||
|
||||
const descTextarea = screen.getByLabelText(/Description/i);
|
||||
fireEvent.change(descTextarea, { target: { value: "Some description" } });
|
||||
|
||||
const createButton = screen.getByRole("button", { name: "Create Task" });
|
||||
expect(createButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("closes modal on cancel button click", () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(props.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes modal on X button click", () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
fireEvent.click(screen.getByText("×"));
|
||||
|
||||
expect(props.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds dependencies via dropdown", async () => {
|
||||
const tasks = [makeTask("KB-010"), makeTask("KB-020")];
|
||||
const { props } = renderNewTaskModal({ tasks });
|
||||
|
||||
// Open dependencies dropdown
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add dependencies" }));
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".dep-dropdown")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click on a task to add it as dependency
|
||||
const items = document.querySelectorAll(".dep-dropdown-item");
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
fireEvent.click(items[0]!);
|
||||
|
||||
// Verify dependency was added (it should show as selected)
|
||||
const descTextarea = screen.getByLabelText(/Description/i);
|
||||
fireEvent.change(descTextarea, { target: { value: "Task with deps" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dependencies: expect.any(Array),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders model selectors when models are loaded", async () => {
|
||||
renderNewTaskModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Executor")).toBeTruthy();
|
||||
expect(screen.getByText("Validator")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles planning mode checkbox", () => {
|
||||
renderNewTaskModal();
|
||||
|
||||
const checkbox = screen.getByLabelText(/Enable planning mode/i) as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(false);
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("shows success toast after creation", async () => {
|
||||
const { props } = renderNewTaskModal({
|
||||
onCreateTask: vi.fn().mockResolvedValue({ id: "KB-042" }),
|
||||
});
|
||||
|
||||
const descTextarea = screen.getByLabelText(/Description/i);
|
||||
fireEvent.change(descTextarea, { target: { value: "Test description" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.addToast).toHaveBeenCalledWith("Created KB-042", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("confirms before closing with dirty state", () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
// Add some content to make it dirty
|
||||
const descTextarea = screen.getByLabelText(/Description/i);
|
||||
fireEvent.change(descTextarea, { target: { value: "Some text" } });
|
||||
|
||||
// Mock confirm to return false (cancel)
|
||||
const originalConfirm = window.confirm;
|
||||
window.confirm = vi.fn().mockReturnValue(false);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledWith("You have unsaved changes. Discard them?");
|
||||
expect(props.onClose).not.toHaveBeenCalled();
|
||||
|
||||
window.confirm = originalConfirm;
|
||||
});
|
||||
|
||||
it("closes without confirm when state is not dirty", () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(props.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates task without title when title is empty", async () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
const descTextarea = screen.getByLabelText(/Description/i);
|
||||
fireEvent.change(descTextarea, { target: { value: "Only description" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: undefined,
|
||||
description: "Only description",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QuickEntryBox } from "../QuickEntryBox";
|
||||
|
||||
function renderQuickEntryBox() {
|
||||
const props = {
|
||||
onCreate: vi.fn().mockResolvedValue(undefined),
|
||||
addToast: vi.fn(),
|
||||
};
|
||||
const result = render(<QuickEntryBox {...props} />);
|
||||
return { ...result, props };
|
||||
}
|
||||
|
||||
describe("QuickEntryBox", () => {
|
||||
it("renders input with placeholder", () => {
|
||||
renderQuickEntryBox();
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
expect(input).toBeTruthy();
|
||||
expect(input.placeholder).toBe("Add a task...");
|
||||
});
|
||||
|
||||
it("creates task on Enter key", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(input, { target: { value: "New task description" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalledWith("New task description");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state during creation", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
// Slow down the promise to see loading state
|
||||
props.onCreate.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(input, { target: { value: "New task" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
// Check loading placeholder
|
||||
await waitFor(() => {
|
||||
expect(input.placeholder).toBe("Creating...");
|
||||
});
|
||||
|
||||
// Input should be disabled during creation
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
|
||||
it("clears input after successful creation", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(input, { target: { value: "Task to create" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
it("shows error toast on failure and keeps input content", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
props.onCreate.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(input, { target: { value: "Failed task" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.addToast).toHaveBeenCalledWith("Network error", "error");
|
||||
});
|
||||
|
||||
// Input content should be preserved for retry
|
||||
expect(input.value).toBe("Failed task");
|
||||
});
|
||||
|
||||
it("clears non-empty input on Escape key", () => {
|
||||
renderQuickEntryBox();
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(input, { target: { value: "Some text" } });
|
||||
expect(input.value).toBe("Some text");
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
it("does not clear empty input on Escape key", () => {
|
||||
renderQuickEntryBox();
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
it("does not submit on Enter if input is empty", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
// Wait a bit to ensure no async call happens
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(props.onCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not submit on Enter if input is only whitespace", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(input, { target: { value: " " } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(props.onCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prevents default on Enter key", () => {
|
||||
renderQuickEntryBox();
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(input, { target: { value: "Task" } });
|
||||
const prevented = !fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(prevented).toBe(true);
|
||||
});
|
||||
|
||||
it("updates input value on change", () => {
|
||||
renderQuickEntryBox();
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(input, { target: { value: "Updated text" } });
|
||||
expect(input.value).toBe("Updated text");
|
||||
});
|
||||
|
||||
it("trims whitespace when creating task", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(input, { target: { value: " Task with spaces " } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalledWith("Task with spaces");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5780,3 +5780,91 @@ html .column.drag-over * {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Quick Entry Box === */
|
||||
.quick-entry-box {
|
||||
padding: 8px 10px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.quick-entry-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.quick-entry-input:focus {
|
||||
border-bottom-color: var(--triage);
|
||||
box-shadow: 0 1px 0 0 var(--triage);
|
||||
}
|
||||
|
||||
.quick-entry-input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.quick-entry-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* === New Task Modal === */
|
||||
.new-task-modal .modal-body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.selected-deps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dep-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.dep-chip-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.dep-chip-remove:hover {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.model-select-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.model-select-label {
|
||||
min-width: 70px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user