feat(FN-1209): add quick entry image attachment flow
- Return the created task from board quick create and propagate the onQuickCreate return type through board/list components - Add pending image handling in QuickEntryBox with paste support, hidden file input, and an Attach action in the menu - Render removable pending image previews, include image count in the actions badge, and clean up object URLs on reset/unmount - Upload pending images after task creation with error toasts for failed uploads, and extend tests for attachment and quick-create behavior
This commit is contained in:
@@ -13,7 +13,7 @@ interface BoardProps {
|
||||
onMoveTask: (id: string, column: ColumnType) => Promise<Task>;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
onQuickCreate?: (input: TaskCreateInput) => Promise<void>;
|
||||
onQuickCreate?: (input: TaskCreateInput) => Promise<Task | void>;
|
||||
onNewTask: () => void;
|
||||
autoMerge: boolean;
|
||||
onToggleAutoMerge: () => void;
|
||||
|
||||
@@ -22,7 +22,7 @@ interface ColumnProps {
|
||||
onMoveTask: (id: string, column: ColumnType) => Promise<Task>;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
onQuickCreate?: (input: TaskCreateInput) => Promise<void>;
|
||||
onQuickCreate?: (input: TaskCreateInput) => Promise<Task | void>;
|
||||
onNewTask?: () => void;
|
||||
autoMerge?: boolean;
|
||||
onToggleAutoMerge?: () => void;
|
||||
|
||||
@@ -101,7 +101,7 @@ interface ListViewProps {
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
globalPaused?: boolean;
|
||||
onNewTask?: () => void;
|
||||
onQuickCreate?: (input: TaskCreateInput) => Promise<void>;
|
||||
onQuickCreate?: (input: TaskCreateInput) => Promise<Task | void>;
|
||||
availableModels?: ModelInfo[];
|
||||
favoriteProviders?: string[];
|
||||
favoriteModels?: string[];
|
||||
|
||||
@@ -3,15 +3,21 @@ import { createPortal } from "react-dom";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import type { Task, TaskCreateInput, Settings } from "@fusion/core";
|
||||
import type { ModelInfo, RefinementType, Agent } from "../api";
|
||||
import { fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents } from "../api";
|
||||
import { Link, Brain, Lightbulb, ListTree, Sparkles, Save, MoreHorizontal, ChevronDown, ChevronUp, ChevronRight, Bot } from "lucide-react";
|
||||
import { fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents, uploadAttachment } from "../api";
|
||||
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, MoreHorizontal, ChevronDown, ChevronUp, ChevronRight, Bot } from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
|
||||
const STORAGE_KEY = "kb-quick-entry-text";
|
||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
|
||||
interface PendingImage {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
}
|
||||
|
||||
interface QuickEntryBoxProps {
|
||||
onCreate?: (input: TaskCreateInput) => Promise<void>;
|
||||
onCreate?: (input: TaskCreateInput) => Promise<Task | void>;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
tasks?: Task[];
|
||||
availableModels?: ModelInfo[];
|
||||
@@ -87,7 +93,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
// Always starts collapsed — user must explicitly toggle each session
|
||||
const [isDisclosureExpanded, setIsDisclosureExpanded] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const justResetRef = useRef(false);
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const pendingImagesRef = useRef<PendingImage[]>([]);
|
||||
|
||||
// Rich creation state (mirrors InlineCreateCard)
|
||||
const [dependencies, setDependencies] = useState<string[]>([]);
|
||||
@@ -200,7 +209,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
: selectedModelCount > 0
|
||||
? `${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
|
||||
: "Models";
|
||||
const actionSelectionCount = dependencies.length + selectedModelCount + (selectedAgentId ? 1 : 0);
|
||||
const actionSelectionCount = dependencies.length + selectedModelCount + pendingImages.length + (selectedAgentId ? 1 : 0);
|
||||
|
||||
const getModelBadgeLabel = useCallback(
|
||||
(provider?: string, modelId?: string) => {
|
||||
@@ -234,10 +243,14 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
setPortalRoot(document.body);
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
pendingImagesRef.current = pendingImages;
|
||||
}, [pendingImages]);
|
||||
|
||||
// Cleanup image preview URLs on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// No blur timeout to clean up
|
||||
pendingImagesRef.current.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -350,6 +363,12 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
}, [showAgentPicker]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||||
setPendingImages([]);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
|
||||
setDescription("");
|
||||
setDependencies([]);
|
||||
setSelectedAgentId(null);
|
||||
@@ -379,7 +398,38 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
if (typeof window !== "undefined") {
|
||||
removeScopedItem(STORAGE_KEY, projectId);
|
||||
}
|
||||
}, [projectId]);
|
||||
}, [pendingImages, projectId]);
|
||||
|
||||
const handleImageFiles = useCallback((files: FileList | null | undefined) => {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const newImages: PendingImage[] = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||
newImages.push({ file, previewUrl: URL.createObjectURL(file) });
|
||||
}
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
setPendingImages((prev) => [...prev, ...newImages]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
if (isSubmitting) return;
|
||||
handleImageFiles(e.clipboardData?.files);
|
||||
}, [handleImageFiles, isSubmitting]);
|
||||
|
||||
const removeImage = useCallback((index: number) => {
|
||||
setPendingImages((prev) => {
|
||||
const removed = prev[index];
|
||||
if (removed) {
|
||||
URL.revokeObjectURL(removed.previewUrl);
|
||||
}
|
||||
return prev.filter((_, i) => i !== index);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const trimmed = description.trim();
|
||||
@@ -387,7 +437,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onCreate({
|
||||
const createdTask = await onCreate({
|
||||
description: trimmed,
|
||||
column: "triage",
|
||||
dependencies: dependencies.length ? dependencies : undefined,
|
||||
@@ -400,6 +450,20 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
planningModelProvider: hasPlanningOverride ? planningProvider : undefined,
|
||||
planningModelId: hasPlanningOverride ? planningModelId : undefined,
|
||||
});
|
||||
if (createdTask && pendingImages.length > 0) {
|
||||
const failures: string[] = [];
|
||||
for (const pendingImage of pendingImages) {
|
||||
try {
|
||||
await uploadAttachment(createdTask.id, pendingImage.file, projectId);
|
||||
} catch {
|
||||
failures.push(pendingImage.file.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
addToast(`Failed to upload: ${failures.join(", ")}`, "error");
|
||||
}
|
||||
}
|
||||
// Clear input for rapid entry
|
||||
resetForm();
|
||||
// Note: Focus restoration is handled by useEffect when isSubmitting becomes false
|
||||
@@ -424,6 +488,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
hasPlanningOverride,
|
||||
planningProvider,
|
||||
planningModelId,
|
||||
pendingImages,
|
||||
projectId,
|
||||
addToast,
|
||||
resetForm,
|
||||
]);
|
||||
@@ -932,6 +998,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={isSubmitting || isDisabled}
|
||||
@@ -1057,6 +1124,25 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pendingImages.length > 0 && (
|
||||
<div className="inline-create-previews">
|
||||
{pendingImages.map((img, index) => (
|
||||
<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(index)}
|
||||
disabled={isSubmitting}
|
||||
title="Remove image"
|
||||
data-testid={`quick-entry-preview-remove-${index}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="quick-entry-controls-left">
|
||||
<div
|
||||
className="quick-entry-actions-wrap dep-trigger-wrap"
|
||||
@@ -1112,6 +1198,19 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
<Link size={12} style={{ verticalAlign: "middle" }} />
|
||||
{dependencies.length > 0 ? `${dependencies.length} deps` : "Deps"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
data-testid="quick-entry-actions-attach"
|
||||
onClick={() => {
|
||||
setIsActionsMenuOpen(false);
|
||||
setActionsMenuPosition(null);
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<Paperclip size={12} style={{ verticalAlign: "middle" }} />
|
||||
{pendingImages.length > 0 ? `Attach (${pendingImages.length})` : "Attach"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
@@ -1376,6 +1475,18 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
portalRoot,
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
handleImageFiles(e.target.files);
|
||||
e.currentTarget.value = "";
|
||||
}}
|
||||
data-testid="quick-entry-file-input"
|
||||
/>
|
||||
<div className="quick-entry-hint">
|
||||
Enter to create · Esc to cancel
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { QuickEntryBox } from "../QuickEntryBox";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { fetchSettings, fetchAgents } from "../../api";
|
||||
import { fetchSettings, fetchAgents, uploadAttachment } from "../../api";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
|
||||
const MOCK_MODELS = [
|
||||
@@ -25,6 +25,19 @@ const MOCK_MODELS = [
|
||||
const TEST_PROJECT_ID = "proj-123";
|
||||
const QUICK_ENTRY_STORAGE_KEY = scopedKey("kb-quick-entry-text", TEST_PROJECT_ID);
|
||||
|
||||
const CREATED_TASK: Task = {
|
||||
id: "FN-999",
|
||||
title: "Created task",
|
||||
description: "Created task description",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-04-08T00:00:00Z",
|
||||
updatedAt: "2026-04-08T00:00:00Z",
|
||||
};
|
||||
|
||||
const mockTasks: Task[] = [
|
||||
{
|
||||
id: "FN-001",
|
||||
@@ -83,12 +96,14 @@ vi.mock("../../api", () => ({
|
||||
refineText: vi.fn(),
|
||||
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
|
||||
fetchAgents: vi.fn().mockResolvedValue([]),
|
||||
uploadAttachment: vi.fn().mockResolvedValue({}),
|
||||
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", () => ({
|
||||
Link: () => null,
|
||||
Paperclip: () => null,
|
||||
Brain: () => null,
|
||||
Lightbulb: () => null,
|
||||
ListTree: () => null,
|
||||
@@ -190,9 +205,23 @@ function clickSaveFromActions() {
|
||||
|
||||
describe("QuickEntryBox", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
localStorage.clear();
|
||||
vi.mocked(fetchAgents).mockResolvedValue([]);
|
||||
vi.mocked(uploadAttachment).mockResolvedValue({} as any);
|
||||
|
||||
Object.defineProperty(URL, "createObjectURL", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn((file: Blob | MediaSource) => `blob:${(file as File).name ?? "mock"}`),
|
||||
});
|
||||
|
||||
Object.defineProperty(URL, "revokeObjectURL", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -1147,6 +1176,126 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("image attachments", () => {
|
||||
it("shows Attach in the actions dropdown", () => {
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
|
||||
openActionsMenu();
|
||||
expect(screen.getByTestId("quick-entry-actions-attach")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking Attach triggers the hidden file input", () => {
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
|
||||
const fileInput = screen.getByTestId("quick-entry-file-input") as HTMLInputElement;
|
||||
const clickSpy = vi.spyOn(fileInput, "click");
|
||||
|
||||
openActionsMenu();
|
||||
fireEvent.click(screen.getByTestId("quick-entry-actions-attach"));
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds a preview when an image is pasted", () => {
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
const file = new File(["image-bytes"], "pasted.png", { type: "image/png" });
|
||||
fireEvent.paste(textarea, { clipboardData: { files: [file] } });
|
||||
|
||||
expect(screen.getByAltText("pasted.png")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("removes pending image previews", () => {
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
const file = new File(["image-bytes"], "remove.png", { type: "image/png" });
|
||||
fireEvent.paste(textarea, { clipboardData: { files: [file] } });
|
||||
|
||||
expect(screen.getByAltText("remove.png")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("quick-entry-preview-remove-0"));
|
||||
expect(screen.queryByAltText("remove.png")).toBeNull();
|
||||
});
|
||||
|
||||
it("uploads each pending image after task creation", async () => {
|
||||
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
||||
renderQuickEntryBox({ onCreate });
|
||||
expandQuickEntry();
|
||||
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
const fileInput = screen.getByTestId("quick-entry-file-input") as HTMLInputElement;
|
||||
const fileA = new File(["a"], "a.png", { type: "image/png" });
|
||||
const fileB = new File(["b"], "b.png", { type: "image/png" });
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Create with images" } });
|
||||
fireEvent.change(fileInput, { target: { files: [fileA, fileB] } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCreate).toHaveBeenCalled();
|
||||
expect(uploadAttachment).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
expect(uploadAttachment).toHaveBeenCalledWith(CREATED_TASK.id, fileA, TEST_PROJECT_ID);
|
||||
expect(uploadAttachment).toHaveBeenCalledWith(CREATED_TASK.id, fileB, TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
it("does not upload attachments when no pending images exist", async () => {
|
||||
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
||||
renderQuickEntryBox({ onCreate });
|
||||
expandQuickEntry();
|
||||
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(textarea, { target: { value: "Create without images" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(uploadAttachment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes pending image count in the actions badge", () => {
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
|
||||
const fileInput = screen.getByTestId("quick-entry-file-input") as HTMLInputElement;
|
||||
const file = new File(["badge"], "badge.png", { type: "image/png" });
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
|
||||
expect(screen.getByTestId("quick-entry-actions-badge").textContent).toBe("1");
|
||||
});
|
||||
|
||||
it("resetForm clears pending images and revokes object URLs", async () => {
|
||||
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
||||
renderQuickEntryBox({ onCreate });
|
||||
expandQuickEntry();
|
||||
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
const fileInput = screen.getByTestId("quick-entry-file-input") as HTMLInputElement;
|
||||
const file = new File(["reset"], "reset.png", { type: "image/png" });
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Create and reset" } });
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
expect(screen.getByAltText("reset.png")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCreate).toHaveBeenCalled();
|
||||
expect(screen.queryByAltText("reset.png")).toBeNull();
|
||||
});
|
||||
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:reset.png");
|
||||
});
|
||||
});
|
||||
|
||||
describe("State sync between isExpanded and isDisclosureExpanded", () => {
|
||||
it("focus then toggle shows controls without collapsing textarea", () => {
|
||||
renderQuickEntryBox();
|
||||
|
||||
@@ -35,16 +35,18 @@ describe("useTaskHandlers", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("handleBoardQuickCreate calls createTask with triage column", async () => {
|
||||
it("handleBoardQuickCreate calls createTask with triage column and returns task", async () => {
|
||||
const options = createOptions();
|
||||
const { result } = renderHook(() => useTaskHandlers(options));
|
||||
const input: TaskCreateInput = { description: "Do work" };
|
||||
|
||||
let created: Task | null = null;
|
||||
await act(async () => {
|
||||
await result.current.handleBoardQuickCreate(input);
|
||||
created = await result.current.handleBoardQuickCreate(input);
|
||||
});
|
||||
|
||||
expect(options.createTask).toHaveBeenCalledWith({ description: "Do work", column: "triage" });
|
||||
expect(created).toEqual(CREATED_TASK);
|
||||
});
|
||||
|
||||
it("handleModalCreate calls createTask with triage column and returns task", async () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ interface UseTaskHandlersOptions {
|
||||
}
|
||||
|
||||
export interface UseTaskHandlersResult {
|
||||
handleBoardQuickCreate: (input: TaskCreateInput) => Promise<void>;
|
||||
handleBoardQuickCreate: (input: TaskCreateInput) => Promise<Task>;
|
||||
handleModalCreate: (input: TaskCreateInput) => Promise<Task>;
|
||||
handlePlanningTaskCreated: (task: Task) => void;
|
||||
handlePlanningTasksCreated: (tasks: Task[]) => void;
|
||||
@@ -29,8 +29,8 @@ export function useTaskHandlers(options: UseTaskHandlersOptions): UseTaskHandler
|
||||
} = options;
|
||||
|
||||
const handleBoardQuickCreate = useCallback(
|
||||
async (input: TaskCreateInput): Promise<void> => {
|
||||
await createTask({ ...input, column: "triage" });
|
||||
async (input: TaskCreateInput): Promise<Task> => {
|
||||
return createTask({ ...input, column: "triage" });
|
||||
},
|
||||
[createTask],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user