From f149bd1292ce266f03b57526d1df89e68dc5d0a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Tue, 2 Jun 2026 13:37:39 +0100 Subject: [PATCH] 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 * 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 * 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 --------- Co-authored-by: Kandev Agent Co-authored-by: Claude Opus 4.7 --- .../components/new-task-dialog.test.tsx | 72 +++++++++++++++++++ .../app/office/components/new-task-dialog.tsx | 52 +++++++++++--- 2 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 apps/web/app/office/components/new-task-dialog.test.tsx diff --git a/apps/web/app/office/components/new-task-dialog.test.tsx b/apps/web/app/office/components/new-task-dialog.test.tsx new file mode 100644 index 0000000000..973cf7df9b --- /dev/null +++ b/apps/web/app/office/components/new-task-dialog.test.tsx @@ -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) { + return render( + + + , + ); +} + +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( + + + , + ); + const button = getButton(); + expect(button.disabled).toBe(true); + expect(button.textContent).toBe("Creating..."); + expect(screen.queryByLabelText(/create a task/i)).toBeNull(); + }); +}); diff --git a/apps/web/app/office/components/new-task-dialog.tsx b/apps/web/app/office/components/new-task-dialog.tsx index c273a5cf47..60a8ba5368 100644 --- a/apps/web/app/office/components/new-task-dialog.tsx +++ b/apps/web/app/office/components/new-task-dialog.tsx @@ -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 - + ); } + +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 = ( + + ); + + if (!reason) return button; + + return ( + + + + {button} + + + {reason} + + ); +}