fix(office): disable create button until project + title set (#1243)
* fix(office): disable create button until project + title set Office "New Task" dialog let user submit without picking a project, which hit the backend isOfficeRequest() heuristic and failed validation with "workflow_id is required for non-ephemeral tasks". Disable the button until both title and project are set and surface the missing requirement in a tooltip instead of a backend error toast. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(office): guard handleCreate on projectId + cover with tests PR review feedback (claude, cubic): the handleCreate path only checked title and workspace, so a keyboard shortcut or future programmatic caller could still produce a request with no project and reproduce the backend 'workflow_id is required' error. Guard it there too, and add unit tests that pin the disabled-state + reason behavior of the extracted CreateTaskButton. aria-label on the trigger wrapper surfaces the reason to assistive tech even when the tooltip is closed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(office): cover submitting state for CreateTaskButton PR review feedback (claude): the disabled check had three branches (missing title / missing project / submitting); only two were tested. Add the submitting case so a regression that drops it from the disabled expression is caught. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
|
||||
import { CreateTaskButton } from "./new-task-dialog";
|
||||
import type { IssueDraft } from "./new-task-draft";
|
||||
|
||||
const BASE_DRAFT: IssueDraft = {
|
||||
title: "",
|
||||
description: "",
|
||||
assigneeId: "",
|
||||
projectId: "",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
showReviewer: false,
|
||||
showApprover: false,
|
||||
reviewerIds: [],
|
||||
approverIds: [],
|
||||
};
|
||||
|
||||
function renderButton(draft: Partial<IssueDraft>) {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<CreateTaskButton draft={{ ...BASE_DRAFT, ...draft }} submitting={false} onCreate={vi.fn()} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const TEST_ID = "new-task-create-button";
|
||||
|
||||
function getButton() {
|
||||
return screen.getByTestId(TEST_ID) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("CreateTaskButton", () => {
|
||||
it("disables the button and labels missing project when no project selected", () => {
|
||||
renderButton({ title: "do thing" });
|
||||
expect(getButton().disabled).toBe(true);
|
||||
// The tooltip content is rendered as accessible-name on the wrapper.
|
||||
expect(screen.getByLabelText(/select a project to create a task/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disables the button and labels missing title when title empty", () => {
|
||||
renderButton({ projectId: "proj-1" });
|
||||
expect(getButton().disabled).toBe(true);
|
||||
expect(screen.getByLabelText(/add a title to create a task/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("enables the button without a tooltip when both title and project are set", () => {
|
||||
renderButton({ title: "do thing", projectId: "proj-1" });
|
||||
expect(getButton().disabled).toBe(false);
|
||||
expect(screen.queryByLabelText(/create a task/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("disables the button and shows 'Creating...' while submitting", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<CreateTaskButton
|
||||
draft={{ ...BASE_DRAFT, title: "do thing", projectId: "proj-1" }}
|
||||
submitting={true}
|
||||
onCreate={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
const button = getButton();
|
||||
expect(button.disabled).toBe(true);
|
||||
expect(button.textContent).toBe("Creating...");
|
||||
expect(screen.queryByLabelText(/create a task/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { Button } from "@kandev/ui/button";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Textarea } from "@kandev/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogFooter, DialogTitle } from "@kandev/ui/dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { toast } from "sonner";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { createTask } from "@/lib/api/domains/kanban-api";
|
||||
@@ -55,7 +56,7 @@ export function NewTaskDialog({
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!draft.title.trim() || !workspaceId) return;
|
||||
if (!draft.title.trim() || !draft.projectId || !workspaceId) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const executionPolicy = buildExecutionPolicy(stages);
|
||||
@@ -168,14 +169,49 @@ function NewIssueDialogBody({
|
||||
>
|
||||
Discard Draft
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCreate}
|
||||
disabled={!draft.title.trim() || submitting}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{submitting ? "Creating..." : "Create Task"}
|
||||
</Button>
|
||||
<CreateTaskButton draft={draft} submitting={submitting} onCreate={onCreate} />
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateTaskButton({
|
||||
draft,
|
||||
submitting,
|
||||
onCreate,
|
||||
}: {
|
||||
draft: IssueDraft;
|
||||
submitting: boolean;
|
||||
onCreate: () => void;
|
||||
}) {
|
||||
const missingTitle = !draft.title.trim();
|
||||
const missingProject = !draft.projectId;
|
||||
const disabled = missingTitle || missingProject || submitting;
|
||||
let reason: string | null = null;
|
||||
if (missingProject) reason = "Select a project to create a task";
|
||||
else if (missingTitle) reason = "Add a title to create a task";
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
onClick={onCreate}
|
||||
disabled={disabled}
|
||||
className="cursor-pointer"
|
||||
data-testid="new-task-create-button"
|
||||
>
|
||||
{submitting ? "Creating..." : "Create Task"}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!reason) return button;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={0} aria-label={reason}>
|
||||
{button}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{reason}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user