feat(KB-603): improve task creation UX with draft persistence and save-to-create
- Change QuickEntryBox Save button to immediately create task instead of just saving - Add draft persistence to InlineCreateCard via localStorage for recovery - Add comprehensive tests for InlineCreateCard draft persistence - Update QuickEntryBox tests for new save-and-create behavior
This commit is contained in:
@@ -8,6 +8,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { applyPresetToSelection } from "../utils/modelPresets";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
const STORAGE_KEY = "kb-inline-create-text";
|
||||
|
||||
interface PendingImage {
|
||||
file: File;
|
||||
@@ -64,7 +65,12 @@ export function InlineCreateCard({
|
||||
onPlanningMode,
|
||||
onSubtaskBreakdown,
|
||||
}: InlineCreateCardProps) {
|
||||
const [description, setDescription] = useState("");
|
||||
const [description, setDescription] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem(STORAGE_KEY) || "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const [dependencies, setDependencies] = useState<string[]>([]);
|
||||
const [showDeps, setShowDeps] = useState(false);
|
||||
const [depSearch, setDepSearch] = useState("");
|
||||
@@ -84,6 +90,13 @@ export function InlineCreateCard({
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Persist description to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(STORAGE_KEY, description);
|
||||
}
|
||||
}, [description]);
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
if (availableModels) {
|
||||
setLoadedModels(availableModels);
|
||||
@@ -214,6 +227,14 @@ export function InlineCreateCard({
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||||
|
||||
// Clear localStorage on unmount if there's no description (user abandoned)
|
||||
if (typeof window !== "undefined") {
|
||||
const current = localStorage.getItem(STORAGE_KEY);
|
||||
if (current && current.trim() === "") {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount
|
||||
}, []);
|
||||
@@ -287,6 +308,11 @@ export function InlineCreateCard({
|
||||
|
||||
setSelectedPresetId(undefined);
|
||||
addToast(`Created ${task.id}`, "success");
|
||||
|
||||
// Clear localStorage after successful task creation
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
} finally {
|
||||
@@ -311,6 +337,10 @@ export function InlineCreateCard({
|
||||
async (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
// Clear localStorage when user explicitly cancels
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -395,15 +395,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
}, [description, onSubtaskBreakdown, addToast, resetForm]);
|
||||
|
||||
const handleSaveClick = useCallback(() => {
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
// Explicitly save to localStorage (even though auto-save already does this)
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(STORAGE_KEY, trimmed);
|
||||
}
|
||||
addToast("Draft saved", "success");
|
||||
}, [description, addToast]);
|
||||
// Save button now creates the task (same as Enter key)
|
||||
handleSubmit();
|
||||
}, [handleSubmit]);
|
||||
|
||||
const handleRefine = useCallback(async (type: RefinementType) => {
|
||||
const trimmed = description.trim();
|
||||
@@ -639,7 +633,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
disabled={!description.trim() || isSubmitting}
|
||||
data-testid="save-button"
|
||||
title="Save draft to browser storage"
|
||||
title="Create task"
|
||||
>
|
||||
<Save size={12} style={{ verticalAlign: "middle" }} />
|
||||
Save
|
||||
|
||||
@@ -90,6 +90,7 @@ function chooseModel(label: "Executor Model" | "Validator Model", optionText: st
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
vi.mocked(fetchModels).mockResolvedValue(MOCK_MODELS);
|
||||
vi.mocked(fetchSettings).mockResolvedValue({
|
||||
modelPresets: [],
|
||||
@@ -536,3 +537,87 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
// The disabled state is the primary UX protection
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineCreateCard localStorage persistence", () => {
|
||||
beforeEach(() => {
|
||||
// Clear localStorage before each test
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("restores description from localStorage on mount", () => {
|
||||
// Pre-populate localStorage
|
||||
localStorage.setItem("kb-inline-create-text", "Saved draft description");
|
||||
|
||||
renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
// Should restore the saved description
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("Saved draft description");
|
||||
});
|
||||
|
||||
it("updates localStorage when typing", async () => {
|
||||
renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Typing this task" } });
|
||||
|
||||
// Wait for the useEffect to run
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("kb-inline-create-text")).toBe("Typing this task");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears localStorage after successful task creation", async () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
// Type something to set localStorage
|
||||
fireEvent.change(textarea, { target: { value: "Task to create" } });
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("kb-inline-create-text")).toBe("Task to create");
|
||||
});
|
||||
|
||||
// Submit the task by clicking the Save button
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// localStorage should be cleared
|
||||
expect(localStorage.getItem("kb-inline-create-text")).toBeNull();
|
||||
});
|
||||
|
||||
it("clears localStorage when cancelling via Escape key", async () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
// Type something to set localStorage
|
||||
fireEvent.change(textarea, { target: { value: "Draft to cancel" } });
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("kb-inline-create-text")).toBe("Draft to cancel");
|
||||
});
|
||||
|
||||
// Press Escape to cancel
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
|
||||
// onCancel should be called
|
||||
await waitFor(() => {
|
||||
expect(props.onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// localStorage should be cleared
|
||||
expect(localStorage.getItem("kb-inline-create-text")).toBeNull();
|
||||
});
|
||||
|
||||
it("starts with empty description when localStorage is empty", () => {
|
||||
renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1036,7 +1036,7 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking save button shows success toast", async () => {
|
||||
it("clicking save button creates the task", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -1046,9 +1046,14 @@ describe("QuickEntryBox", () => {
|
||||
// Click the save button
|
||||
fireEvent.click(screen.getByTestId("save-button"));
|
||||
|
||||
// Success toast should be shown
|
||||
// Task should be created
|
||||
await waitFor(() => {
|
||||
expect(props.addToast).toHaveBeenCalledWith("Draft saved", "success");
|
||||
expect(props.onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Task to save",
|
||||
column: "triage",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1072,7 +1077,7 @@ describe("QuickEntryBox", () => {
|
||||
fireEvent.change(textarea, { target: { value: "Task to save" } });
|
||||
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
expect(saveButton.getAttribute("title")).toBe("Save draft to browser storage");
|
||||
expect(saveButton.getAttribute("title")).toBe("Create task");
|
||||
});
|
||||
|
||||
it("save button prevents textarea blur on mousedown", () => {
|
||||
|
||||
Reference in New Issue
Block a user