mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: select template version for tasks (#20146)
Allows users with `updateTemplates` permission to select a specific template version when creating a task. One of the biggest changes was moving `TaskPrompt` into `modules/task` and adding a Storybook entry for it. I also moved some stories from `TasksPage` to simplify its story. <img width="1208" height="197" alt="Screenshot 2025-10-02 at 12 09 06" src="https://github.com/user-attachments/assets/b85d2723-bb52-442b-b8eb-36721944a653" /> Closes https://github.com/coder/coder/issues/19986
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import {
|
||||
MockAIPromptPresets,
|
||||
MockNewTaskData,
|
||||
MockPresets,
|
||||
MockTask,
|
||||
MockTasks,
|
||||
MockTemplate,
|
||||
MockTemplateVersion,
|
||||
MockTemplateVersionExternalAuthGithub,
|
||||
MockTemplateVersionExternalAuthGithubAuthenticated,
|
||||
MockUserOwner,
|
||||
mockApiError,
|
||||
} from "testHelpers/entities";
|
||||
import { withAuthProvider, withGlobalSnackbar } from "testHelpers/storybook";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { API } from "api/api";
|
||||
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
|
||||
import type TasksPage from "../../../pages/TasksPage/TasksPage";
|
||||
import { TaskPrompt } from "./TaskPrompt";
|
||||
|
||||
const meta: Meta<typeof TasksPage> = {
|
||||
title: "modules/tasks/TaskPrompt",
|
||||
component: TaskPrompt,
|
||||
decorators: [withAuthProvider],
|
||||
parameters: {
|
||||
user: MockUserOwner,
|
||||
permissions: {
|
||||
updateTemplates: true,
|
||||
},
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([]);
|
||||
spyOn(API, "getTemplates").mockResolvedValue([
|
||||
MockTemplate,
|
||||
{
|
||||
...MockTemplate,
|
||||
id: "test-template-2",
|
||||
name: "template 2",
|
||||
display_name: "Template 2",
|
||||
},
|
||||
]);
|
||||
spyOn(API, "getTemplateVersions").mockResolvedValue([
|
||||
{
|
||||
...MockTemplateVersion,
|
||||
name: "v1.0.0",
|
||||
},
|
||||
]);
|
||||
spyOn(API, "getTemplateVersionPresets").mockResolvedValue(null);
|
||||
},
|
||||
args: {
|
||||
templates: [MockTemplate],
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TasksPage>;
|
||||
|
||||
export const LoadingTemplates: Story = {
|
||||
args: {
|
||||
templates: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyTemplates: Story = {
|
||||
args: {
|
||||
templates: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithPresets: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplateVersionPresets").mockResolvedValue(MockPresets);
|
||||
},
|
||||
};
|
||||
|
||||
export const ReadOnlyPresetPrompt: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplateVersionPresets").mockResolvedValue(
|
||||
MockAIPromptPresets,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OnSuccess: Story = {
|
||||
decorators: [withGlobalSnackbar],
|
||||
parameters: {
|
||||
permissions: {
|
||||
updateTemplates: false,
|
||||
},
|
||||
},
|
||||
beforeEach: () => {
|
||||
const activeVersionId = `${MockTemplate.active_version_id}-latest`;
|
||||
spyOn(API, "getTemplate").mockResolvedValue({
|
||||
...MockTemplate,
|
||||
active_version_id: activeVersionId,
|
||||
});
|
||||
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Run task", async () => {
|
||||
const prompt = await canvas.findByLabelText(/prompt/i);
|
||||
await userEvent.type(prompt, MockNewTaskData.prompt);
|
||||
const submitButton = canvas.getByRole("button", { name: /run task/i });
|
||||
await waitFor(() => expect(submitButton).toBeEnabled());
|
||||
await userEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await step("Uses latest template version", () => {
|
||||
expect(API.experimental.createTask).toHaveBeenCalledWith(
|
||||
MockUserOwner.id,
|
||||
{
|
||||
input: MockNewTaskData.prompt,
|
||||
template_version_id: `${MockTemplate.active_version_id}-latest`,
|
||||
template_version_preset_id: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await step("Displays success message", async () => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const successMessage = await body.findByText(/task created/i);
|
||||
expect(successMessage).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectTemplateVersion: Story = {
|
||||
decorators: [withGlobalSnackbar],
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplateVersions").mockResolvedValue([
|
||||
{
|
||||
...MockTemplateVersion,
|
||||
id: "test-template-version-2",
|
||||
name: "v2.0.0",
|
||||
},
|
||||
{
|
||||
...MockTemplateVersion,
|
||||
name: "v1.0.0",
|
||||
},
|
||||
]);
|
||||
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Fill prompt", async () => {
|
||||
const prompt = await canvas.findByLabelText(/prompt/i);
|
||||
await userEvent.type(prompt, MockNewTaskData.prompt);
|
||||
});
|
||||
|
||||
await step("Select version", async () => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const versionSelect = await canvas.findByLabelText(/template version/i);
|
||||
await userEvent.click(versionSelect);
|
||||
const versionOption = await body.findByRole("option", {
|
||||
name: /v2.0.0/i,
|
||||
});
|
||||
await userEvent.click(versionOption);
|
||||
});
|
||||
|
||||
await step("Submit form", async () => {
|
||||
const submitButton = canvas.getByRole("button", { name: /run task/i });
|
||||
await waitFor(() => expect(submitButton).toBeEnabled());
|
||||
await userEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await step("Uses selected version", () => {
|
||||
expect(API.experimental.createTask).toHaveBeenCalledWith(
|
||||
MockUserOwner.id,
|
||||
{
|
||||
input: MockNewTaskData.prompt,
|
||||
template_version_id: "test-template-version-2",
|
||||
template_version_preset_id: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await step("Displays success message", async () => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const successMessage = await body.findByText(/task created/i);
|
||||
expect(successMessage).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const OnError: Story = {
|
||||
decorators: [withGlobalSnackbar],
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(API, "getTemplate").mockResolvedValue(MockTemplate);
|
||||
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
|
||||
spyOn(API.experimental, "createTask").mockRejectedValue(
|
||||
mockApiError({
|
||||
message: "Failed to create task",
|
||||
detail: "You don't have permission to create tasks.",
|
||||
}),
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Run task", async () => {
|
||||
const prompt = await canvas.findByLabelText(/prompt/i);
|
||||
await userEvent.type(prompt, "Create a new task");
|
||||
const submitButton = canvas.getByRole("button", { name: /run task/i });
|
||||
await waitFor(() => expect(submitButton).toBeEnabled());
|
||||
await userEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await step("Verify error", async () => {
|
||||
await canvas.findByText(/failed to create task/i);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const AuthenticatedExternalAuth: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getTasks")
|
||||
.mockResolvedValueOnce(MockTasks)
|
||||
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
|
||||
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
|
||||
spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
|
||||
MockTemplateVersionExternalAuthGithubAuthenticated,
|
||||
]);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Does not render external auth", async () => {
|
||||
expect(
|
||||
canvas.queryByText(/external authentication/),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
parameters: {
|
||||
chromatic: {
|
||||
disableSnapshot: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MissingExternalAuth: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getTasks")
|
||||
.mockResolvedValueOnce(MockTasks)
|
||||
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
|
||||
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
|
||||
spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
|
||||
MockTemplateVersionExternalAuthGithub,
|
||||
]);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Submit is disabled", async () => {
|
||||
const prompt = await canvas.findByLabelText(/prompt/i);
|
||||
await userEvent.type(prompt, MockNewTaskData.prompt);
|
||||
const submitButton = canvas.getByRole("button", { name: /run task/i });
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
await step("Renders external authentication", async () => {
|
||||
await canvas.findByRole("button", { name: /connect to github/i });
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ExternalAuthError: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getTasks")
|
||||
.mockResolvedValueOnce(MockTasks)
|
||||
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
|
||||
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
|
||||
spyOn(API, "getTemplateVersionExternalAuth").mockRejectedValue(
|
||||
mockApiError({
|
||||
message: "Failed to load external auth",
|
||||
}),
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Submit is disabled", async () => {
|
||||
const prompt = await canvas.findByLabelText(/prompt/i);
|
||||
await userEvent.type(prompt, MockNewTaskData.prompt);
|
||||
const submitButton = canvas.getByRole("button", { name: /run task/i });
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
await step("Renders error", async () => {
|
||||
await canvas.findByText(/failed to load external auth/i);
|
||||
});
|
||||
},
|
||||
};
|
||||
+85
-27
@@ -1,7 +1,10 @@
|
||||
import type { SelectTriggerProps } from "@radix-ui/react-select";
|
||||
import { API } from "api/api";
|
||||
import { getErrorDetail, getErrorMessage } from "api/errors";
|
||||
import { templateVersionPresets } from "api/queries/templates";
|
||||
import {
|
||||
templateVersionPresets,
|
||||
templateVersions,
|
||||
} from "api/queries/templates";
|
||||
import type {
|
||||
Preset,
|
||||
Task,
|
||||
@@ -135,12 +138,14 @@ type CreateTaskFormProps = {
|
||||
};
|
||||
|
||||
const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
||||
const { user } = useAuthenticated();
|
||||
const { user, permissions } = useAuthenticated();
|
||||
const queryClient = useQueryClient();
|
||||
const [prompt, setPrompt] = useState("");
|
||||
|
||||
// Template
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(
|
||||
templates[0].id,
|
||||
);
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string>();
|
||||
const selectedTemplate = templates.find(
|
||||
(t) => t.id === selectedTemplateId,
|
||||
) as Template;
|
||||
@@ -152,24 +157,38 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
||||
isLoadingExternalAuth,
|
||||
} = useExternalAuth(selectedTemplate.active_version_id);
|
||||
|
||||
// Fetch presets when template changes
|
||||
const { data: presets, isLoading: isLoadingPresets } = useQuery(
|
||||
templateVersionPresets(selectedTemplate.active_version_id),
|
||||
// Template versions
|
||||
const [selectedVersionId, setSelectedVersionId] = useState(
|
||||
selectedTemplate.active_version_id,
|
||||
);
|
||||
const defaultPreset = presets?.find((p) => p.Default);
|
||||
const versionsQuery = useQuery({
|
||||
...templateVersions(selectedTemplate.id),
|
||||
enabled: permissions.updateTemplates,
|
||||
});
|
||||
|
||||
// Handle preset selection when data changes
|
||||
// Presets
|
||||
const { data: presets, isLoading: isLoadingPresets } = useQuery(
|
||||
templateVersionPresets(selectedVersionId),
|
||||
);
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string>();
|
||||
useEffect(() => {
|
||||
setSelectedPresetId(defaultPreset?.ID);
|
||||
}, [defaultPreset?.ID]);
|
||||
|
||||
// Extract AI prompt from selected preset
|
||||
const defaultPreset = presets?.find((p) => p.Default);
|
||||
setSelectedPresetId(defaultPreset?.ID ?? presets?.[0]?.ID);
|
||||
}, [presets]);
|
||||
const selectedPreset = presets?.find((p) => p.ID === selectedPresetId);
|
||||
const presetAIPrompt = selectedPreset?.Parameters?.find(
|
||||
|
||||
// Read-only prompt if defined in preset
|
||||
const presetPrompt = selectedPreset?.Parameters?.find(
|
||||
(param) => param.Name === AI_PROMPT_PARAMETER_NAME,
|
||||
)?.Value;
|
||||
const isPromptReadOnly = !!presetAIPrompt;
|
||||
const isPromptReadOnly = !!presetPrompt;
|
||||
useEffect(() => {
|
||||
if (presetPrompt) {
|
||||
setPrompt(presetPrompt);
|
||||
}
|
||||
}, [presetPrompt]);
|
||||
|
||||
// External Auth
|
||||
const missedExternalAuth = externalAuth?.filter(
|
||||
(auth) => !auth.optional && !auth.authenticated,
|
||||
);
|
||||
@@ -178,13 +197,26 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
||||
: true;
|
||||
|
||||
const createTaskMutation = useMutation({
|
||||
mutationFn: async ({ prompt }: CreateTaskMutationFnProps) =>
|
||||
createTaskWithLatestTemplateVersion(
|
||||
mutationFn: async ({ prompt }: CreateTaskMutationFnProps) => {
|
||||
// Users with updateTemplates permission can select the version to use.
|
||||
if (permissions.updateTemplates) {
|
||||
return API.experimental.createTask(user.id, {
|
||||
input: prompt,
|
||||
template_version_id: selectedVersionId,
|
||||
template_version_preset_id: selectedPresetId,
|
||||
});
|
||||
}
|
||||
|
||||
// For regular users we want to enforce task creation to always use the latest
|
||||
// active template version, to avoid issues when the active version changes
|
||||
// between template load and user action.
|
||||
return createTaskWithLatestTemplateVersion(
|
||||
prompt,
|
||||
user.id,
|
||||
selectedTemplate.id,
|
||||
selectedPresetId,
|
||||
),
|
||||
);
|
||||
},
|
||||
onSuccess: async (task) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
onSuccess(task);
|
||||
@@ -194,10 +226,6 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const prompt = presetAIPrompt || (formData.get("prompt") as string);
|
||||
|
||||
try {
|
||||
await createTaskMutation.mutateAsync({
|
||||
prompt,
|
||||
@@ -225,7 +253,7 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
||||
htmlFor="prompt"
|
||||
className={
|
||||
isPromptReadOnly
|
||||
? "text-xs font-medium text-content-primary mb-2 block"
|
||||
? "text-xs font-medium text-content-primary block px-3 pt-2"
|
||||
: "sr-only"
|
||||
}
|
||||
>
|
||||
@@ -233,12 +261,13 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
||||
</label>
|
||||
<PromptTextarea
|
||||
required
|
||||
value={presetAIPrompt || undefined}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
readOnly={isPromptReadOnly}
|
||||
/>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div>
|
||||
<label htmlFor="templateID" className="sr-only">
|
||||
Template
|
||||
</label>
|
||||
@@ -265,7 +294,34 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
{versionsQuery.data && (
|
||||
<div>
|
||||
<label htmlFor="versionId" className="sr-only">
|
||||
Template version
|
||||
</label>
|
||||
<Select
|
||||
name="versionId"
|
||||
onValueChange={(value) => setSelectedVersionId(value)}
|
||||
value={selectedVersionId}
|
||||
required
|
||||
>
|
||||
<PromptSelectTrigger id="versionId">
|
||||
<SelectValue placeholder="Select a version" />
|
||||
</PromptSelectTrigger>
|
||||
<SelectContent>
|
||||
{versionsQuery.data.map((version) => {
|
||||
return (
|
||||
<SelectItem value={version.id} key={version.id}>
|
||||
{version.name}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="presetID" className="sr-only">
|
||||
Preset
|
||||
</label>
|
||||
@@ -273,11 +329,12 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
||||
<Skeleton className="w-[320px] h-8" />
|
||||
) : (
|
||||
presets &&
|
||||
presets.length > 0 && (
|
||||
presets.length > 0 &&
|
||||
selectedPresetId && (
|
||||
<Select
|
||||
key={`preset-select-${selectedTemplate.active_version_id}`}
|
||||
name="presetID"
|
||||
value={selectedPresetId || undefined}
|
||||
value={selectedPresetId}
|
||||
onValueChange={setSelectedPresetId}
|
||||
>
|
||||
<PromptSelectTrigger id="presetID">
|
||||
@@ -438,6 +495,7 @@ async function createTaskWithLatestTemplateVersion(
|
||||
const PromptTextarea: FC<TextareaAutosizeProps> = (props) => {
|
||||
return (
|
||||
<TextareaAutosize
|
||||
{...props}
|
||||
required
|
||||
id="prompt"
|
||||
name="prompt"
|
||||
@@ -1,25 +1,14 @@
|
||||
import {
|
||||
MockAIPromptPresets,
|
||||
MockNewTaskData,
|
||||
MockPresets,
|
||||
MockTask,
|
||||
MockTasks,
|
||||
MockTemplate,
|
||||
MockTemplateVersionExternalAuthGithub,
|
||||
MockTemplateVersionExternalAuthGithubAuthenticated,
|
||||
MockUserOwner,
|
||||
mockApiError,
|
||||
} from "testHelpers/entities";
|
||||
import {
|
||||
withAuthProvider,
|
||||
withGlobalSnackbar,
|
||||
withProxyProvider,
|
||||
} from "testHelpers/storybook";
|
||||
import { withAuthProvider, withProxyProvider } from "testHelpers/storybook";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { API } from "api/api";
|
||||
import { MockUsers } from "pages/UsersPage/storybookData/users";
|
||||
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import { expect, spyOn, userEvent, within } from "storybook/test";
|
||||
import TasksPage from "./TasksPage";
|
||||
|
||||
const meta: Meta<typeof TasksPage> = {
|
||||
@@ -54,7 +43,7 @@ const meta: Meta<typeof TasksPage> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TasksPage>;
|
||||
|
||||
export const LoadingAITemplates: Story = {
|
||||
export const LoadingTemplates: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplates").mockImplementation(
|
||||
() => new Promise(() => 1000 * 60 * 60),
|
||||
@@ -62,7 +51,7 @@ export const LoadingAITemplates: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadingAITemplatesError: Story = {
|
||||
export const LoadingTemplatesError: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplates").mockRejectedValue(
|
||||
mockApiError({
|
||||
@@ -73,13 +62,6 @@ export const LoadingAITemplatesError: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyAITemplates: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplates").mockResolvedValue([]);
|
||||
spyOn(API.experimental, "getTasks").mockResolvedValue([]);
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadingTasks: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||
@@ -123,58 +105,6 @@ export const LoadedTasks: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadedTasksWithPresets: Story = {
|
||||
beforeEach: () => {
|
||||
const mockTemplateWithPresets = {
|
||||
...MockTemplate,
|
||||
id: "test-template-2",
|
||||
name: "template-with-presets",
|
||||
display_name: "Template with Presets",
|
||||
};
|
||||
|
||||
spyOn(API, "getTemplates").mockResolvedValue([
|
||||
MockTemplate,
|
||||
mockTemplateWithPresets,
|
||||
]);
|
||||
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
|
||||
spyOn(API, "getTemplateVersionPresets").mockImplementation(
|
||||
async (versionId) => {
|
||||
// Return presets only for the second template
|
||||
if (versionId === mockTemplateWithPresets.active_version_id) {
|
||||
return MockPresets;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadedTasksWithAIPromptPresets: Story = {
|
||||
beforeEach: () => {
|
||||
const mockTemplateWithPresets = {
|
||||
...MockTemplate,
|
||||
id: "test-template-2",
|
||||
name: "template-with-presets",
|
||||
display_name: "Template with AI Prompt Presets",
|
||||
};
|
||||
|
||||
spyOn(API, "getTemplates").mockResolvedValue([
|
||||
MockTemplate,
|
||||
mockTemplateWithPresets,
|
||||
]);
|
||||
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
|
||||
spyOn(API, "getTemplateVersionPresets").mockImplementation(
|
||||
async (versionId) => {
|
||||
// Return presets only for the second template
|
||||
if (versionId === mockTemplateWithPresets.active_version_id) {
|
||||
return MockAIPromptPresets;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadedTasksWaitingForInput: Story = {
|
||||
beforeEach: () => {
|
||||
const [firstTask, ...otherTasks] = MockTasks;
|
||||
@@ -225,184 +155,6 @@ export const LoadedTasksWaitingForInputTab: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const CreateTaskSuccessfully: Story = {
|
||||
decorators: [withGlobalSnackbar],
|
||||
parameters: {
|
||||
reactRouter: reactRouterParameters({
|
||||
location: {
|
||||
path: "/tasks",
|
||||
},
|
||||
routing: [
|
||||
{
|
||||
path: "/tasks",
|
||||
useStoryElement: true,
|
||||
},
|
||||
{
|
||||
path: "/tasks/:ownerName/:workspaceName",
|
||||
element: <h1>Task page</h1>,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
beforeEach: () => {
|
||||
const activeVersionId = `${MockTemplate.active_version_id}-latest`;
|
||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(API, "getTemplate").mockResolvedValue({
|
||||
...MockTemplate,
|
||||
active_version_id: activeVersionId,
|
||||
});
|
||||
spyOn(API.experimental, "getTasks")
|
||||
.mockResolvedValueOnce(MockTasks)
|
||||
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
|
||||
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Run task", async () => {
|
||||
const prompt = await canvas.findByLabelText(/prompt/i);
|
||||
await userEvent.type(prompt, MockNewTaskData.prompt);
|
||||
const submitButton = canvas.getByRole("button", { name: /run task/i });
|
||||
await waitFor(() => expect(submitButton).toBeEnabled());
|
||||
await userEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await step("Uses latest template version", () => {
|
||||
expect(API.experimental.createTask).toHaveBeenCalledWith(
|
||||
MockUserOwner.id,
|
||||
{
|
||||
input: MockNewTaskData.prompt,
|
||||
template_version_id: `${MockTemplate.active_version_id}-latest`,
|
||||
template_version_preset_id: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await step("Displays success message", async () => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const successMessage = await body.findByText(/task created/i);
|
||||
expect(successMessage).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await step("Find task in the table", async () => {
|
||||
const table = canvasElement.querySelector("table");
|
||||
await waitFor(() => {
|
||||
expect(table).toHaveTextContent(MockNewTaskData.prompt);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const CreateTaskError: Story = {
|
||||
decorators: [withGlobalSnackbar],
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(API, "getTemplate").mockResolvedValue(MockTemplate);
|
||||
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
|
||||
spyOn(API.experimental, "createTask").mockRejectedValue(
|
||||
mockApiError({
|
||||
message: "Failed to create task",
|
||||
detail: "You don't have permission to create tasks.",
|
||||
}),
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Run task", async () => {
|
||||
const prompt = await canvas.findByLabelText(/prompt/i);
|
||||
await userEvent.type(prompt, "Create a new task");
|
||||
const submitButton = canvas.getByRole("button", { name: /run task/i });
|
||||
await waitFor(() => expect(submitButton).toBeEnabled());
|
||||
await userEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await step("Verify error", async () => {
|
||||
await canvas.findByText(/failed to create task/i);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAuthenticatedExternalAuth: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getTasks")
|
||||
.mockResolvedValueOnce(MockTasks)
|
||||
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
|
||||
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
|
||||
spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
|
||||
MockTemplateVersionExternalAuthGithubAuthenticated,
|
||||
]);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Does not render external auth", async () => {
|
||||
expect(
|
||||
canvas.queryByText(/external authentication/),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
parameters: {
|
||||
chromatic: {
|
||||
disableSnapshot: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MissingExternalAuth: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getTasks")
|
||||
.mockResolvedValueOnce(MockTasks)
|
||||
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
|
||||
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
|
||||
spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
|
||||
MockTemplateVersionExternalAuthGithub,
|
||||
]);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Submit is disabled", async () => {
|
||||
const prompt = await canvas.findByLabelText(/prompt/i);
|
||||
await userEvent.type(prompt, MockNewTaskData.prompt);
|
||||
const submitButton = canvas.getByRole("button", { name: /run task/i });
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
await step("Renders external authentication", async () => {
|
||||
await canvas.findByRole("button", { name: /connect to github/i });
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ExternalAuthError: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getTasks")
|
||||
.mockResolvedValueOnce(MockTasks)
|
||||
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
|
||||
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
|
||||
spyOn(API, "getTemplateVersionExternalAuth").mockRejectedValue(
|
||||
mockApiError({
|
||||
message: "Failed to load external auth",
|
||||
}),
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Submit is disabled", async () => {
|
||||
const prompt = await canvas.findByLabelText(/prompt/i);
|
||||
await userEvent.type(prompt, MockNewTaskData.prompt);
|
||||
const submitButton = canvas.getByRole("button", { name: /run task/i });
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
await step("Renders error", async () => {
|
||||
await canvas.findByText(/failed to load external auth/i);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const NonAdmin: Story = {
|
||||
parameters: {
|
||||
permissions: {
|
||||
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
} from "components/PageHeader/PageHeader";
|
||||
import { useAuthenticated } from "hooks";
|
||||
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
|
||||
import { TaskPrompt } from "modules/tasks/TaskPrompt/TaskPrompt";
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { cn } from "utils/cn";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { TaskPrompt } from "./TaskPrompt";
|
||||
import { TasksTable } from "./TasksTable";
|
||||
import { UsersCombobox } from "./UsersCombobox";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user