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:
João Salavisa
2026-06-02 13:37:39 +01:00
committed by GitHub
parent cb7b5429d5
commit f149bd1292
2 changed files with 116 additions and 8 deletions
@@ -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>
);
}