fix: clarify required automation fields (#1312)
This commit is contained in:
@@ -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 (
|
||||
<div className="max-w-3xl space-y-6" data-testid="automation-editor">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="automation-name">Name</Label>
|
||||
<RequiredFieldLabel htmlFor="automation-name">Name</RequiredFieldLabel>
|
||||
<Input
|
||||
id="automation-name"
|
||||
data-testid="automation-name-input"
|
||||
value={form.name}
|
||||
onChange={(e) => 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() && (
|
||||
<p id="automation-name-help" className="text-xs text-muted-foreground">
|
||||
Enter an automation name to enable saving.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<WhenSection
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockState = {
|
||||
workflows: {
|
||||
items: [{ id: "workflow-1", name: "Build" }],
|
||||
},
|
||||
agentProfiles: {
|
||||
items: [],
|
||||
},
|
||||
executors: {
|
||||
items: [],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
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<ComponentProps<typeof ConfigSection>> = {}) {
|
||||
return render(
|
||||
<ConfigSection
|
||||
workspaceId="workspace-1"
|
||||
workflowId=""
|
||||
workflowStepId=""
|
||||
agentProfileId=""
|
||||
executorProfileId=""
|
||||
repositorySelection={{ kind: "none" }}
|
||||
executionMode="task"
|
||||
conditionType={null}
|
||||
onWorkflowChange={() => {}}
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<StepOption[]>([]);
|
||||
|
||||
@@ -190,7 +197,7 @@ export function ConfigSection({
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Configuration
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<SelectField
|
||||
testId="execution-mode-selector"
|
||||
label="Execution Mode"
|
||||
@@ -269,20 +276,23 @@ function WorkflowFields({
|
||||
<SelectField
|
||||
testId="workflow-selector"
|
||||
label="Workflow"
|
||||
required
|
||||
value={workflowId}
|
||||
onChange={onWorkflowChange}
|
||||
placeholder="Select workflow"
|
||||
items={workflows.map((w) => ({ id: w.id, label: w.name }))}
|
||||
helpText={!hasWorkflow ? "Select a workflow to enable saving." : undefined}
|
||||
/>
|
||||
<SelectField
|
||||
testId="workflow-step-selector"
|
||||
label="Workflow Step"
|
||||
required
|
||||
value={workflowStepId}
|
||||
onChange={onStepChange}
|
||||
placeholder={hasWorkflow ? "Select step" : "Pick a workflow first"}
|
||||
items={steps.map((s) => ({ 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 (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{label}</Label>
|
||||
{required ? (
|
||||
<RequiredFieldLabel className="text-xs">{label}</RequiredFieldLabel>
|
||||
) : (
|
||||
<Label className="text-xs">{label}</Label>
|
||||
)}
|
||||
<Select value={value || undefined} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger data-testid={testId} className="cursor-pointer">
|
||||
<SelectTrigger
|
||||
data-testid={testId}
|
||||
className="cursor-pointer"
|
||||
aria-describedby={invalid ? helpId : undefined}
|
||||
aria-invalid={invalid ? true : undefined}
|
||||
>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -322,7 +345,11 @@ function SelectField({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{helpText && <p className="text-[10px] text-muted-foreground">{helpText}</p>}
|
||||
{helpText && (
|
||||
<p id={helpId} className="text-[10px] text-muted-foreground">
|
||||
{helpText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof Label>) {
|
||||
return (
|
||||
<Label className={cn("gap-1.5", className)} {...props}>
|
||||
<span>{children}</span>
|
||||
<span aria-hidden className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
<span className="sr-only">required</span>
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user