diff --git a/apps/web/components/automations/automation-editor.tsx b/apps/web/components/automations/automation-editor.tsx index 605b632bd3..24564d06e8 100644 --- a/apps/web/components/automations/automation-editor.tsx +++ b/apps/web/components/automations/automation-editor.tsx @@ -30,6 +30,7 @@ import { PromptSection } from "./prompt-section"; import { ConfigSection } from "./config-section"; import { RunsSection } from "./runs-section"; import { WebhookCreatedDialog } from "./webhook-created-dialog"; +import { RequiredFieldLabel } from "./required-field-label"; import { type CreatedWebhookDetails, type FormState, @@ -353,14 +354,21 @@ export function AutomationEditor({ workspaceId, automationId }: AutomationEditor return (
- + Name updateField("name", e.target.value)} placeholder="Automation name" + aria-describedby={!form.name.trim() ? "automation-name-help" : undefined} + aria-invalid={!form.name.trim() ? true : undefined} /> + {!form.name.trim() && ( +

+ Enter an automation name to enable saving. +

+ )}
({ + useAppStore: (selector: (state: typeof mockState) => unknown) => selector(mockState), +})); + +vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ + useSettingsData: vi.fn(), +})); + +vi.mock("@/hooks/use-workflows", () => ({ + useWorkflows: vi.fn(), +})); + +vi.mock("@/hooks/domains/workspace/use-repositories", () => ({ + useRepositories: () => ({ repositories: [] }), +})); + +vi.mock("@/app/actions/workspaces", () => ({ + discoverRepositoriesAction: vi.fn().mockResolvedValue({ repositories: [] }), +})); + +vi.mock("@/lib/api/domains/workflow-api", () => ({ + listWorkflowSteps: vi.fn().mockResolvedValue({ steps: [] }), +})); + +import { ConfigSection } from "./config-section"; + +function renderConfigSection(overrides: Partial> = {}) { + return render( + {}} + onStepChange={() => {}} + onAgentProfileChange={() => {}} + onExecutorProfileChange={() => {}} + onRepositoryChange={() => {}} + onExecutionModeChange={() => {}} + {...overrides} + />, + ); +} + +describe("ConfigSection", () => { + afterEach(cleanup); + + it("marks task workflow fields as required and explains missing selections", () => { + renderConfigSection(); + + screen.getByText("Workflow"); + screen.getByText("Workflow Step"); + expect(screen.getAllByText("required")).toHaveLength(2); + screen.getByText("Select a workflow to enable saving."); + screen.getByText("Select a workflow before choosing a step."); + expect(screen.getByTestId("workflow-selector").getAttribute("aria-describedby")).toBe( + "workflow-selector-help", + ); + }); + + it("changes step help text once a workflow is selected", () => { + renderConfigSection({ workflowId: "workflow-1" }); + + expect(screen.queryByText("Select a workflow to enable saving.")).toBeNull(); + expect(screen.queryByText("Select a workflow before choosing a step.")).toBeNull(); + screen.getByText("Select a workflow step to enable saving."); + expect(screen.getByTestId("workflow-step-selector").getAttribute("aria-describedby")).toBe( + "workflow-step-selector-help", + ); + }); + + it("hides workflow required markers in run mode", () => { + renderConfigSection({ executionMode: "run" }); + + expect(screen.queryByText("Workflow")).toBeNull(); + expect(screen.queryByText("Workflow Step")).toBeNull(); + expect(screen.queryAllByText("required")).toHaveLength(0); + }); +}); diff --git a/apps/web/components/automations/config-section.tsx b/apps/web/components/automations/config-section.tsx index 2f8000431e..f317b8a956 100644 --- a/apps/web/components/automations/config-section.tsx +++ b/apps/web/components/automations/config-section.tsx @@ -11,6 +11,7 @@ import { discoverRepositoriesAction } from "@/app/actions/workspaces"; import { listWorkflowSteps } from "@/lib/api/domains/workflow-api"; import type { LocalRepository, Repository } from "@/lib/types/http"; import type { ExecutionMode, TriggerType } from "@/lib/types/automation"; +import { RequiredFieldLabel } from "./required-field-label"; // RepositorySelection mirrors the task-create dialog's two-tier model: a // registered workspace repository (keyed by id) OR a filesystem-discovered @@ -121,6 +122,12 @@ function useDiscoveredRepositories(workspaceId: string) { type StepOption = { id: string; name: string }; +function getWorkflowStepHelpText(workflowId: string, workflowStepId: string): string | undefined { + if (!workflowId) return "Select a workflow before choosing a step."; + if (!workflowStepId) return "Select a workflow step to enable saving."; + return undefined; +} + function useWorkflowSteps(workflowId: string) { const [steps, setSteps] = useState([]); @@ -190,7 +197,7 @@ export function ConfigSection({ -
+
({ id: w.id, label: w.name }))} + helpText={!hasWorkflow ? "Select a workflow to enable saving." : undefined} /> ({ id: s.id, label: s.name }))} disabled={!hasWorkflow} - helpText={hasWorkflow ? undefined : "Pick a workflow above to load its steps."} + helpText={getWorkflowStepHelpText(workflowId, workflowStepId)} /> ); @@ -297,6 +307,7 @@ function SelectField({ items, disabled, helpText, + required, }: { testId?: string; label: string; @@ -306,12 +317,24 @@ function SelectField({ items: Array<{ id: string; label: string }>; disabled?: boolean; helpText?: string; + required?: boolean; }) { + const invalid = required && !value; + const helpId = testId && helpText ? `${testId}-help` : undefined; return (
- + {required ? ( + {label} + ) : ( + + )} - {helpText &&

{helpText}

} + {helpText && ( +

+ {helpText} +

+ )}
); } diff --git a/apps/web/components/automations/required-field-label.tsx b/apps/web/components/automations/required-field-label.tsx new file mode 100644 index 0000000000..ccca3fc717 --- /dev/null +++ b/apps/web/components/automations/required-field-label.tsx @@ -0,0 +1,19 @@ +import type { ComponentProps } from "react"; +import { Label } from "@kandev/ui/label"; +import { cn } from "@/lib/utils"; + +export function RequiredFieldLabel({ + children, + className, + ...props +}: ComponentProps) { + return ( + + ); +}