Files
2026-07-13 13:32:57 +08:00

1905 lines
72 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getFormProps, getInputProps, getSelectProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import {
CheckCircleIcon,
RectangleStackIcon,
StarIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
import { DialogClose, DialogDescription } from "@radix-ui/react-dialog";
import { Form, useActionData, useFetcher, useParams, useSubmit } from "@remix-run/react";
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { MachinePresetName } from "@trigger.dev/core/v3";
import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { BeakerIcon } from "~/assets/icons/BeakerIcon";
import { ClockRotateLeftIcon } from "~/assets/icons/ClockRotateLeftIcon";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { JSONEditor } from "~/components/code/JSONEditor";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { Badge } from "~/components/primitives/Badge";
import { Button } from "~/components/primitives/Buttons";
import { DateField } from "~/components/primitives/DateField";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { DurationPicker } from "~/components/primitives/DurationPicker";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { Select, SelectItem } from "~/components/primitives/Select";
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import { TextLink } from "~/components/primitives/TextLink";
import { RunTagInput } from "~/components/runs/v3/RunTagInput";
import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus";
import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource";
import { TimezoneList } from "~/components/scheduled/timezones";
import { $replica } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useSearchParams } from "~/hooks/useSearchParam";
import {
redirectBackWithErrorMessage,
redirectWithErrorMessage,
redirectWithSuccessMessage,
} from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { type Region, RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
import {
type RunTemplate,
type ScheduledRun,
type ScheduledTaskResult,
type StandardRun,
type StandardTaskResult,
TestTaskPresenter,
} from "~/presenters/v3/TestTaskPresenter.server";
import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { docsPath, v3RunSpanPath, v3TaskParamsSchema, v3TestPath } from "~/utils/pathBuilder";
import { DeleteTaskRunTemplateService } from "~/v3/services/deleteTaskRunTemplate.server";
import { TaskRunTemplateService } from "~/v3/services/taskRunTemplate.server";
import { TestTaskService } from "~/v3/services/testTask.server";
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
import { DeleteTaskRunTemplateData, RunTemplateData } from "~/v3/taskRunTemplate";
import { TestTaskData } from "~/v3/testTask";
import { AIPayloadTabContent } from "./AIPayloadTabContent";
import { SchemaTabContent } from "./SchemaTabContent";
import { TestSidebarTabs } from "./TestSidebarTabs";
import { Header2 } from "~/components/primitives/Headers";
type FormAction = "create-template" | "delete-template" | "run-scheduled" | "run-standard";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { projectParam, organizationSlug, envParam, taskParam } = v3TaskParamsSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
throw new Response(undefined, {
status: 404,
statusText: "Environment not found",
});
}
try {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
);
const presenter = new TestTaskPresenter($replica, clickhouse);
const [result, regionsResult] = await Promise.all([
presenter.call({
userId: user.id,
projectId: project.id,
taskIdentifier: taskParam,
environment: environment,
}),
new RegionsPresenter().call({
userId: user.id,
projectSlug: projectParam,
isAdmin: user.admin || user.isImpersonating,
}),
]);
return typedjson({ ...result, regions: regionsResult.regions });
} catch (error) {
logger.error("Failed to load test page", {
taskParam,
error: error instanceof Error ? error.message : error,
stack: error instanceof Error ? error.stack : undefined,
});
return redirectWithErrorMessage(
v3TestPath({ slug: organizationSlug }, { slug: projectParam }, environment),
request,
`Couldn't load test page for ${taskParam}`
);
}
};
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUser(request);
const { organizationSlug, projectParam, envParam } = v3TaskParamsSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
return redirectBackWithErrorMessage(request, "Project not found");
}
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
return redirectBackWithErrorMessage(request, "Environment not found");
}
const formData = await request.formData();
const formAction = formData.get("formAction") as FormAction;
switch (formAction) {
case "create-template": {
const submission = parseWithZod(formData, { schema: RunTemplateData });
if (submission.status !== "success") {
return json({
...submission.reply(),
formAction,
});
}
const templateService = new TaskRunTemplateService();
try {
const template = await templateService.call(environment, submission.value);
return json({
...submission.reply(),
success: true,
templateLabel: template.label,
formAction,
});
} catch (e) {
logger.error("Failed to create template", { error: e instanceof Error ? e.message : e });
return redirectBackWithErrorMessage(request, "Failed to create template");
}
}
case "delete-template": {
const submission = parseWithZod(formData, { schema: DeleteTaskRunTemplateData });
if (submission.status !== "success") {
return json({
...submission.reply(),
formAction,
});
}
const deleteService = new DeleteTaskRunTemplateService();
try {
await deleteService.call(environment, submission.value.templateId);
return json({
...submission.reply(),
success: true,
formAction,
});
} catch (e) {
logger.error("Failed to delete template", { error: e instanceof Error ? e.message : e });
return redirectBackWithErrorMessage(request, "Failed to delete template");
}
}
case "run-scheduled":
case "run-standard": {
const submission = parseWithZod(formData, { schema: TestTaskData });
if (submission.status !== "success") {
return json({
...submission.reply(),
formAction,
});
}
if (environment.archivedAt) {
return redirectBackWithErrorMessage(request, "Can't run a test on an archived environment");
}
const testService = new TestTaskService();
try {
const run = await testService.call(environment, submission.value);
if (!run) {
return redirectBackWithErrorMessage(
request,
"Unable to start a test run: Something went wrong"
);
}
return redirectWithSuccessMessage(
v3RunSpanPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
{ friendlyId: run.friendlyId },
{ spanId: run.spanId }
),
request,
"Test run created"
);
} catch (e) {
if (e instanceof OutOfEntitlementError) {
return redirectBackWithErrorMessage(
request,
"Unable to start a test run: You have exceeded your free credits"
);
}
logger.error("Failed to start a test run", { error: e instanceof Error ? e.message : e });
return redirectBackWithErrorMessage(
request,
"Unable to start a test run: Something went wrong"
);
}
}
default: {
formAction satisfies never;
return redirectBackWithErrorMessage(request, "Failed to process request");
}
}
};
export default function Page() {
const result = useTypedLoaderData<typeof loader>();
if (!result.foundTask) {
return <div></div>;
}
const params = useParams();
const queueFetcher = useFetcher<typeof queuesLoader>();
useEffect(() => {
if (params.organizationSlug && params.projectParam && params.envParam) {
const searchParams = new URLSearchParams();
searchParams.set("type", "custom");
searchParams.set("per_page", "100");
queueFetcher.load(
`/resources/orgs/${params.organizationSlug}/projects/${params.projectParam}/env/${
params.envParam
}/queues?${searchParams.toString()}`
);
}
}, [params.organizationSlug, params.projectParam, params.envParam]);
const defaultTaskQueue = result.queue;
const queues = useMemo(() => {
const customQueues = queueFetcher.data?.queues ?? [];
return defaultTaskQueue && !customQueues.some((q) => q.id === defaultTaskQueue.id)
? [defaultTaskQueue, ...customQueues]
: customQueues;
}, [queueFetcher.data?.queues, defaultTaskQueue]);
const { triggerSource } = result;
switch (triggerSource) {
case "STANDARD": {
return (
<StandardTaskForm
task={result.task}
queues={queues}
runs={result.runs}
versions={result.latestVersions}
templates={result.taskRunTemplates}
disableVersionSelection={result.disableVersionSelection}
allowArbitraryQueues={result.allowArbitraryQueues}
regions={result.regions}
/>
);
}
case "SCHEDULED": {
return (
<ScheduledTaskForm
task={result.task}
queues={queues}
runs={result.runs}
versions={result.latestVersions}
templates={result.taskRunTemplates}
possibleTimezones={result.possibleTimezones}
disableVersionSelection={result.disableVersionSelection}
allowArbitraryQueues={result.allowArbitraryQueues}
regions={result.regions}
/>
);
}
default: {
return triggerSource satisfies never;
}
}
}
const startingJson = "{\n\n}";
const machinePresets = Object.values(MachinePresetName.enum);
function StandardTaskForm({
task,
queues,
runs,
versions,
templates,
disableVersionSelection,
allowArbitraryQueues,
regions,
}: {
task: StandardTaskResult["task"];
queues: Required<StandardTaskResult>["queue"][];
runs: StandardRun[];
versions: string[];
templates: RunTemplate[];
disableVersionSelection: boolean;
allowArbitraryQueues: boolean;
regions: Region[];
}) {
const environment = useEnvironment();
const { value, replace } = useSearchParams();
const tab = value("tab");
const submit = useSubmit();
const actionData = useActionData<typeof action>();
const lastSubmission =
actionData &&
typeof actionData === "object" &&
"formAction" in actionData &&
actionData.formAction === ("run-standard" satisfies FormAction)
? actionData
: undefined;
const lastRun = runs.at(0);
const [defaultPayloadJson, setDefaultPayloadJson] = useState<string>(
lastRun?.payload ?? startingJson
);
const setPayload = useCallback((code: string) => {
setDefaultPayloadJson(code);
}, []);
const currentPayloadJson = useRef<string>(defaultPayloadJson);
const [defaultMetadataJson, setDefaultMetadataJson] = useState<string>(
lastRun?.seedMetadata ?? startingJson
);
const setMetadata = useCallback((code: string) => {
setDefaultMetadataJson(code);
}, []);
const currentMetadataJson = useRef<string>(defaultMetadataJson);
const [ttlValue, setTtlValue] = useState<number | undefined>(lastRun?.ttlSeconds);
const [concurrencyKeyValue, setConcurrencyKeyValue] = useState<string | undefined>(
lastRun?.concurrencyKey
);
const [queueValue, setQueueValue] = useState<string | undefined>(lastRun?.queue);
const [machineValue, setMachineValue] = useState<string | undefined>(lastRun?.machinePreset);
const isDev = environment.type === "DEVELOPMENT";
const defaultRegion = regions.find((r) => r.isDefault);
const [regionValue, setRegionValue] = useState<string | undefined>(
isDev ? undefined : defaultRegion?.name
);
const [maxAttemptsValue, setMaxAttemptsValue] = useState<number | undefined>(
lastRun?.maxAttempts
);
const [maxDurationValue, setMaxDurationValue] = useState<number | undefined>(
lastRun?.maxDurationInSeconds
);
const [tagsValue, setTagsValue] = useState<string[]>(lastRun?.runTags ?? []);
const [sidebarTab, setSidebarTab] = useState("options");
const regionItems = regions.map((r) => ({
value: r.name,
label: r.description ? `${r.name}${r.description}` : r.name,
isDefault: r.isDefault,
}));
const queueItems = queues.map((q) => ({
value: q.type === "task" ? `task/${q.name}` : q.name,
label: q.name,
type: q.type,
paused: q.paused,
}));
const [showTemplateCreatedSuccessMessage, setShowTemplateCreatedSuccessMessage] = useState(false);
const [
form,
{
environmentId,
payload,
metadata,
taskIdentifier,
delaySeconds,
ttlSeconds,
idempotencyKey,
idempotencyKeyTTLSeconds,
queue,
concurrencyKey,
maxAttempts,
maxDurationSeconds,
triggerSource,
tags,
version,
machine,
region,
prioritySeconds,
},
] = useForm({
id: "test-task",
// TODO: type this
lastResult: lastSubmission as any,
onSubmit(event, { formData }) {
event.preventDefault();
formData.set(payload.name, currentPayloadJson.current);
formData.set(metadata.name, currentMetadataJson.current);
submit(formData, { method: "POST" });
},
onValidate({ formData }) {
return parseWithZod(formData, { schema: TestTaskData });
},
});
return (
<Form className="flex h-full max-h-full flex-col" method="post" {...getFormProps(form)}>
<input {...getInputProps(taskIdentifier, { type: "hidden" })} value={task.taskIdentifier} />
<input {...getInputProps(environmentId, { type: "hidden" })} value={environment.id} />
<input {...getInputProps(triggerSource, { type: "hidden" })} value={"STANDARD"} />
<div className="flex items-center justify-between gap-1.5 border-b border-grid-bright p-2">
<div className="flex items-center gap-1.5">
<TaskTriggerSourceIcon source={"STANDARD"} />
<Paragraph variant="extra-small" className="text-text-dimmed">
{task.taskIdentifier}
</Paragraph>
</div>
<div className="flex items-center gap-1.5">
<RunTemplatesPopover
templates={templates}
onTemplateSelected={(template) => {
setPayload(template.payload ?? "");
setMetadata(template.metadata ?? "");
setTtlValue(template.ttlSeconds ?? 0);
setConcurrencyKeyValue(template.concurrencyKey ?? "");
setMaxAttemptsValue(template.maxAttempts ?? undefined);
setMaxDurationValue(template.maxDurationSeconds ?? 0);
setMachineValue(template.machinePreset ?? undefined);
setTagsValue(template.tags ?? []);
setQueueValue(template.queue ?? undefined);
}}
showTemplateCreatedSuccessMessage={showTemplateCreatedSuccessMessage}
/>
<RecentRunsPopover
runs={runs}
onRunSelected={(run) => {
setPayload(run.payload);
run.seedMetadata && setMetadata(run.seedMetadata);
setTtlValue(run.ttlSeconds);
setConcurrencyKeyValue(run.concurrencyKey);
setMaxAttemptsValue(run.maxAttempts);
setMaxDurationValue(run.maxDurationInSeconds);
setTagsValue(run.runTags ?? []);
setQueueValue(run.queue);
setMachineValue(run.machinePreset);
}}
/>
</div>
</div>
<ResizablePanelGroup orientation="horizontal" className="grow">
<ResizablePanel id="test-task-main" min="300px">
<div className="flex h-full flex-col overflow-hidden bg-background-deep">
<div className="flex-1 overflow-hidden">
<JSONEditor
defaultValue={!tab || tab === "payload" ? defaultPayloadJson : defaultMetadataJson}
readOnly={false}
basicSetup
onChange={(v) => {
if (!tab || tab === "payload") {
currentPayloadJson.current = v;
setPayload(v);
} else {
currentMetadataJson.current = v;
setMetadata(v);
}
}}
height="100%"
autoFocus={true}
className={cn("h-full overflow-auto")}
additionalActions={
<TabContainer className="flex grow items-baseline justify-between self-end border-none">
<div className="flex gap-5">
<TabButton
isActive={!tab || tab === "payload"}
layoutId="test-editor"
onClick={() => {
replace({ tab: "payload" });
}}
>
Payload
</TabButton>
<TabButton
isActive={tab === "metadata"}
layoutId="test-editor"
onClick={() => {
replace({ tab: "metadata" });
}}
>
Metadata
</TabButton>
</div>
</TabContainer>
}
/>
</div>
</div>
</ResizablePanel>
<ResizableHandle id="test-task-handle" />
<ResizablePanel id="test-task-options" min="300px" default="380px" max="600px">
<TestSidebarTabs
activeTab={sidebarTab}
onTabChange={setSidebarTab}
optionsContent={
<Fieldset className="px-3 py-3">
<Hint>
Options enable you to control the execution behavior of your task.{" "}
<TextLink to={docsPath("triggering#options")}>Read the docs.</TextLink>
</Hint>
<InputGroup>
<Label htmlFor={machine.id} variant="small">
Machine
</Label>
<Select
{...getSelectProps(machine)}
variant="tertiary/small"
placeholder="Select machine type"
dropdownIcon
items={machinePresets}
defaultValue={undefined}
value={machineValue}
setValue={(e) => {
if (Array.isArray(e)) return;
setMachineValue(e);
}}
>
{machinePresets.map((machine) => (
<SelectItem key={machine} value={machine}>
{machine}
</SelectItem>
))}
</Select>
<Hint>Overrides the machine preset.</Hint>
<FormError id={machine.errorId}>{machine.errors}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={version.id} variant="small">
Version
</Label>
<Select
{...getSelectProps(version)}
defaultValue="latest"
variant="tertiary/small"
placeholder="Select version"
dropdownIcon
disabled={disableVersionSelection}
>
{versions.map((version, i) => (
<SelectItem key={version} value={i === 0 ? "latest" : version}>
{version} {i === 0 && "(latest)"}
</SelectItem>
))}
</Select>
{disableVersionSelection ? (
<Hint>
Only the latest version is available in the development environment.
</Hint>
) : (
<Hint>Runs task on a specific version.</Hint>
)}
<FormError id={version.errorId}>{version.errors}</FormError>
</InputGroup>
{regionItems.length > 1 && (
<InputGroup>
<Label htmlFor={region.id} variant="small">
Region
</Label>
{/* Our Select primitive uses Ariakit under the hood, which treats
value={undefined} as uncontrolled, keeping stale internal state when
switching environments. The key forces a remount so it reinitializes
with the correct defaultValue. */}
<Select
{...getSelectProps(region)}
key={`region-${environment.id}`}
variant="tertiary/small"
placeholder={isDev ? "" : undefined}
dropdownIcon
items={regionItems}
defaultValue={isDev ? undefined : defaultRegion?.name}
value={isDev ? undefined : regionValue}
setValue={
isDev
? undefined
: (e) => {
if (Array.isArray(e)) return;
setRegionValue(e);
}
}
disabled={isDev}
>
{regionItems.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
{r.isDefault ? " (default)" : ""}
</SelectItem>
))}
</Select>
{isDev ? (
<Hint>Region is not available in the development environment.</Hint>
) : (
<Hint>Overrides the region for this run.</Hint>
)}
<FormError id={region.errorId}>{region.errors}</FormError>
</InputGroup>
)}
<InputGroup>
<Label htmlFor={queue.id} variant="small">
Queue
</Label>
{allowArbitraryQueues ? (
<Input
{...getInputProps(queue, { type: "text" })}
variant="small"
value={queueValue ?? ""}
onChange={(e) => setQueueValue(e.target.value)}
/>
) : (
<Select
name={queue.name}
id={queue.id}
placeholder="Select queue"
heading="Filter queues"
variant="tertiary/small"
dropdownIcon
items={queueItems}
filter={{ keys: ["label"] }}
value={queueValue}
setValue={setQueueValue}
>
{(matches) =>
matches.map((queueItem) => (
<SelectItem
key={queueItem.value}
value={queueItem.value}
className="max-w-(--popover-anchor-width)"
icon={
queueItem.type === "task" ? (
<TaskIcon className="size-4 shrink-0 text-blue-500" />
) : (
<RectangleStackIcon className="size-4 shrink-0 text-purple-500" />
)
}
>
<div className="flex w-full min-w-0 items-center justify-between">
<span className="truncate">{queueItem.label}</span>
{queueItem.paused && (
<Badge variant="extra-small" className="ml-1 text-warning">
Paused
</Badge>
)}
</div>
</SelectItem>
))
}
</Select>
)}
<Hint>Assign run to a specific queue.</Hint>
<FormError id={queue.errorId}>{queue.errors}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={tags.id} variant="small">
Tags
</Label>
<RunTagInput
name={tags.name}
id={tags.id}
variant="small"
tags={tagsValue}
onTagsChange={setTagsValue}
/>
<Hint>Add tags to easily filter runs.</Hint>
<FormError id={tags.errorId}>{tags.errors}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={maxAttempts.id} variant="small">
Max attempts
</Label>
<Input
{...getInputProps(maxAttempts, { type: "number" })}
className="[&::-webkit-inner-spin-button]:appearance-none"
variant="small"
min={1}
value={maxAttemptsValue}
onChange={(e) =>
setMaxAttemptsValue(e.target.value ? parseInt(e.target.value) : undefined)
}
onKeyDown={(e) => {
// only allow entering integers > 1
if (["-", "+", ".", "e", "E"].includes(e.key)) {
e.preventDefault();
}
}}
onBlur={(e) => {
const value = parseInt(e.target.value);
if (value < 1 && e.target.value !== "") {
e.target.value = "1";
}
}}
/>
<Hint>Retries failed runs up to the specified number of attempts.</Hint>
<FormError id={maxAttempts.errorId}>{maxAttempts.errors}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">Max duration</Label>
<DurationPicker
name={maxDurationSeconds.name}
id={maxDurationSeconds.id}
value={maxDurationValue}
onChange={setMaxDurationValue}
/>
<Hint>Overrides the maximum compute time limit for the run.</Hint>
<FormError id={maxDurationSeconds.errorId}>{maxDurationSeconds.errors}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={idempotencyKey.id} variant="small">
Idempotency key
</Label>
<Input {...getInputProps(idempotencyKey, { type: "text" })} variant="small" />
<FormError id={idempotencyKey.errorId}>{idempotencyKey.errors}</FormError>
<Hint>
Specify an idempotency key to ensure that a task is only triggered once with the
same key.
</Hint>
</InputGroup>
<InputGroup>
<Label variant="small">Idempotency key TTL</Label>
<DurationPicker
name={idempotencyKeyTTLSeconds.name}
id={idempotencyKeyTTLSeconds.id}
/>
<Hint>Keys expire after 30 days by default.</Hint>
<FormError id={idempotencyKeyTTLSeconds.errorId}>
{idempotencyKeyTTLSeconds.errors}
</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={concurrencyKey.id} variant="small">
Concurrency key
</Label>
<Input
{...getInputProps(concurrencyKey, { type: "text" })}
variant="small"
value={concurrencyKeyValue ?? ""}
onChange={(e) => setConcurrencyKeyValue(e.target.value)}
/>
<Hint>
Limits concurrency by creating a separate queue for each value of the key.
</Hint>
<FormError id={concurrencyKey.errorId}>{concurrencyKey.errors}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">Delay</Label>
<DurationPicker name={delaySeconds.name} id={delaySeconds.id} />
<Hint>Delays run by a specific duration.</Hint>
<FormError id={delaySeconds.errorId}>{delaySeconds.errors}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">Priority</Label>
<DurationPicker name={prioritySeconds.name} id={prioritySeconds.id} />
<Hint>Sets the priority of the run. Higher values mean higher priority.</Hint>
<FormError id={prioritySeconds.errorId}>{prioritySeconds.errors}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">TTL</Label>
<DurationPicker
name={ttlSeconds.name}
id={ttlSeconds.id}
value={ttlValue}
onChange={setTtlValue}
/>
<Hint>Expires the run if it hasn't started within the TTL.</Hint>
<FormError id={ttlSeconds.errorId}>{ttlSeconds.errors}</FormError>
</InputGroup>
<FormError>{form.errors}</FormError>
</Fieldset>
}
aiContent={
<AIPayloadTabContent
onPayloadGenerated={setPayload}
payloadSchema={task.payloadSchema ?? task.inferredPayloadSchema}
taskIdentifier={task.taskIdentifier}
getCurrentPayload={() => currentPayloadJson.current}
/>
}
schemaContent={
<SchemaTabContent
schema={task.payloadSchema}
inferredSchema={task.inferredPayloadSchema}
/>
}
/>
</ResizablePanel>
</ResizablePanelGroup>
<div className="flex items-center justify-end gap-3 border-t border-grid-bright bg-background-dimmed p-2">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<Paragraph variant="small" className="whitespace-nowrap">
This test will run in
</Paragraph>
<EnvironmentCombo environment={environment} className="gap-0.5" />
</div>
<CreateTemplateModal
rawTestTaskFormData={{
environmentId: environment.id,
taskIdentifier: task.taskIdentifier,
triggerSource: "STANDARD",
ttlSeconds: ttlValue?.toString(),
queue: queueValue,
concurrencyKey: concurrencyKeyValue,
maxAttempts: maxAttemptsValue?.toString(),
maxDurationSeconds: maxDurationValue?.toString(),
tags: tagsValue.join(","),
machine: machineValue,
}}
getCurrentPayload={() => currentPayloadJson.current}
getCurrentMetadata={() => currentMetadataJson.current}
setShowCreatedSuccessMessage={setShowTemplateCreatedSuccessMessage}
/>
<Button
type="submit"
variant="primary/medium"
LeadingIcon={BeakerIcon}
iconSpacing="gap-x-2.5"
leadingIconClassName="-mx-2"
shortcut={{ key: "enter", modifiers: ["mod"], enabledOnInputElements: true }}
name="formAction"
value={"run-standard" satisfies FormAction}
>
Run test
</Button>
</div>
</div>
</Form>
);
}
function ScheduledTaskForm({
task,
runs,
possibleTimezones,
queues,
versions,
templates,
disableVersionSelection,
allowArbitraryQueues,
regions,
}: {
task: ScheduledTaskResult["task"];
runs: ScheduledRun[];
possibleTimezones: string[];
queues: Required<ScheduledTaskResult>["queue"][];
versions: string[];
templates: RunTemplate[];
disableVersionSelection: boolean;
allowArbitraryQueues: boolean;
regions: Region[];
}) {
const environment = useEnvironment();
const lastRun = runs.at(0);
const [timestampValue, setTimestampValue] = useState<Date | undefined>(
lastRun?.payload?.timestamp ?? new Date()
);
const [lastTimestampValue, setLastTimestampValue] = useState<Date | undefined>(
lastRun?.payload?.lastTimestamp
);
const [externalIdValue, setExternalIdValue] = useState<string | undefined>(
lastRun?.payload?.externalId
);
const [timezoneValue, setTimezoneValue] = useState<string>(lastRun?.payload?.timezone ?? "UTC");
const [ttlValue, setTtlValue] = useState<number | undefined>(lastRun?.ttlSeconds);
const [concurrencyKeyValue, setConcurrencyKeyValue] = useState<string | undefined>(
lastRun?.concurrencyKey
);
const [queueValue, setQueueValue] = useState<string | undefined>(lastRun?.queue);
const [machineValue, setMachineValue] = useState<string | undefined>(lastRun?.machinePreset);
const isDev = environment.type === "DEVELOPMENT";
const defaultRegion = regions.find((r) => r.isDefault);
const [regionValue, setRegionValue] = useState<string | undefined>(
isDev ? undefined : defaultRegion?.name
);
const [maxAttemptsValue, setMaxAttemptsValue] = useState<number | undefined>(
lastRun?.maxAttempts
);
const [maxDurationValue, setMaxDurationValue] = useState<number | undefined>(
lastRun?.maxDurationInSeconds
);
const [tagsValue, setTagsValue] = useState<string[]>(lastRun?.runTags ?? []);
const [showTemplateCreatedSuccessMessage, setShowTemplateCreatedSuccessMessage] = useState(false);
const regionItems = regions.map((r) => ({
value: r.name,
label: r.description ? `${r.name} — ${r.description}` : r.name,
isDefault: r.isDefault,
}));
const queueItems = queues.map((q) => ({
value: q.type === "task" ? `task/${q.name}` : q.name,
label: q.name,
type: q.type,
paused: q.paused,
}));
const actionData = useActionData<typeof action>();
const lastSubmission =
actionData &&
typeof actionData === "object" &&
"formAction" in actionData &&
actionData.formAction === ("run-scheduled" satisfies FormAction)
? actionData
: undefined;
const [
form,
{
timestamp,
lastTimestamp,
externalId,
triggerSource,
taskIdentifier,
environmentId,
timezone,
ttlSeconds,
idempotencyKey,
idempotencyKeyTTLSeconds,
queue,
concurrencyKey,
maxAttempts,
maxDurationSeconds,
tags,
version,
machine,
region,
prioritySeconds,
},
] = useForm({
id: "test-task-scheduled",
// TODO: type this
lastResult: lastSubmission as any,
onValidate({ formData }) {
return parseWithZod(formData, { schema: TestTaskData });
},
});
return (
<Form
className="grid h-full max-h-full grid-rows-[1fr_auto]"
method="post"
{...getFormProps(form)}
>
<input {...getInputProps(triggerSource, { type: "hidden" })} value={"SCHEDULED"} />
<input {...getInputProps(taskIdentifier, { type: "hidden" })} value={task.taskIdentifier} />
<input {...getInputProps(environmentId, { type: "hidden" })} value={environment.id} />
{/* Main area: scrolling form with the toolbar floating on top-right in the same grid cell */}
<div className="grid min-h-0 grid-cols-1 grid-rows-1 overflow-hidden">
<div className="col-start-1 row-start-1 overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="mx-auto w-fit p-3">
<div className="mb-6 flex items-center gap-1.5">
<TaskTriggerSourceIcon source={"SCHEDULED"} />
<Header2 className="text-text-bright">{task.taskIdentifier}</Header2>
</div>
<Fieldset>
<InputGroup>
<Label htmlFor={timestamp.id} variant="small">
Timestamp UTC
</Label>
<input
{...getInputProps(timestamp, { type: "hidden" })}
value={timestampValue?.toISOString() ?? ""}
/>
<DateField
label="Timestamp UTC"
defaultValue={timestampValue}
onValueChange={(val) => setTimestampValue(val)}
granularity="second"
showNowButton
variant="small"
utc
/>
<Hint>
This is the timestamp of the CRON, it will come through to your run in the
payload.
</Hint>
<FormError id={timestamp.errorId}>{timestamp.errors}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={lastTimestamp.id} variant="small">
Last timestamp UTC
</Label>
<input
{...getInputProps(lastTimestamp, { type: "hidden" })}
value={lastTimestampValue?.toISOString() ?? ""}
/>
<DateField
label="Last timestamp UTC"
defaultValue={lastTimestampValue}
onValueChange={(val) => setLastTimestampValue(val)}
granularity="second"
showNowButton
showClearButton
variant="small"
utc
/>
<Hint>
This is the timestamp of the previous run. You can use this in your code to find
new data since the previous run.
</Hint>
<FormError id={lastTimestamp.errorId}>{lastTimestamp.errors}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={timezone.id} variant="small">
Timezone
</Label>
<Select
{...getSelectProps(timezone)}
placeholder="Select a timezone"
defaultValue={timezoneValue}
value={timezoneValue}
setValue={(e) => {
if (Array.isArray(e)) return;
setTimezoneValue(e);
}}
items={possibleTimezones}
filter={{ keys: [(item) => item.replace(/\//g, " ").replace(/_/g, " ")] }}
dropdownIcon
variant="tertiary/small"
>
{(matches) => <TimezoneList timezones={matches} />}
</Select>
<Hint>
The Timestamp and Last timestamp are in UTC so this just changes the timezone
string that comes through in the payload.
</Hint>
<FormError id={timezone.errorId}>{timezone.errors}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={externalId.id} variant="small">
External ID
</Label>
<Input
{...getInputProps(externalId, { type: "text" })}
placeholder="Optionally specify your own ID, e.g. user id"
value={externalIdValue ?? ""}
onChange={(e) => setExternalIdValue(e.target.value)}
variant="small"
/>
<Hint>
Optionally, you can specify your own IDs (like a user ID) and then use it inside
the run function of your task.{" "}
<TextLink to={docsPath("v3/tasks-scheduled")}>Read the docs.</TextLink>
</Hint>
<FormError id={externalId.errorId}>{externalId.errors}</FormError>
</InputGroup>
<div className="w-full border-b border-grid-bright" />
<Hint>
Options enable you to control the execution behavior of your task.{" "}
<TextLink to={docsPath("triggering#options")}>Read the docs.</TextLink>
</Hint>
<InputGroup>
<Label htmlFor={machine.id} variant="small">
Machine
</Label>
<Select
{...getSelectProps(machine)}
variant="tertiary/small"
placeholder="Select machine type"
dropdownIcon
items={machinePresets}
defaultValue={undefined}
value={machineValue}
setValue={(e) => {
if (Array.isArray(e)) return;
setMachineValue(e);
}}
>
{machinePresets.map((machine) => (
<SelectItem key={machine} value={machine}>
{machine}
</SelectItem>
))}
</Select>
<Hint>Overrides the machine preset.</Hint>
<FormError id={machine.errorId}>{machine.errors}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={version.id} variant="small">
Version
</Label>
<Select
{...getSelectProps(version)}
defaultValue="latest"
variant="tertiary/small"
placeholder="Select version"
dropdownIcon
disabled={disableVersionSelection}
>
{versions.map((version, i) => (
<SelectItem key={version} value={i === 0 ? "latest" : version}>
{version} {i === 0 && "(latest)"}
</SelectItem>
))}
</Select>
{disableVersionSelection ? (
<Hint>Only the latest version is available in the development environment.</Hint>
) : (
<Hint>Runs task on a specific version.</Hint>
)}
<FormError id={version.errorId}>{version.errors}</FormError>
</InputGroup>
{regionItems.length > 1 && (
<InputGroup>
<Label htmlFor={region.id} variant="small">
Region
</Label>
{/* Our Select primitive uses Ariakit under the hood, which treats
value={undefined} as uncontrolled, keeping stale internal state when
switching environments. The key forces a remount so it reinitializes
with the correct defaultValue. */}
<Select
{...getSelectProps(region)}
key={`region-${environment.id}`}
variant="tertiary/small"
placeholder={isDev ? "" : undefined}
dropdownIcon
items={regionItems}
defaultValue={isDev ? undefined : defaultRegion?.name}
value={isDev ? undefined : regionValue}
setValue={
isDev
? undefined
: (e) => {
if (Array.isArray(e)) return;
setRegionValue(e);
}
}
disabled={isDev}
>
{regionItems.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
{r.isDefault ? " (default)" : ""}
</SelectItem>
))}
</Select>
{isDev ? (
<Hint>Region is not available in the development environment.</Hint>
) : (
<Hint>Overrides the region for this run.</Hint>
)}
<FormError id={region.errorId}>{region.errors}</FormError>
</InputGroup>
)}
<InputGroup>
<Label htmlFor={queue.id} variant="small">
Queue
</Label>
{allowArbitraryQueues ? (
<Input
{...getInputProps(queue, { type: "text" })}
variant="small"
value={queueValue ?? ""}
onChange={(e) => setQueueValue(e.target.value)}
/>
) : (
<Select
name={queue.name}
id={queue.id}
placeholder="Select queue"
heading="Filter queues"
variant="tertiary/small"
dropdownIcon
items={queueItems}
filter={{ keys: ["label"] }}
value={queueValue}
setValue={setQueueValue}
>
{(matches) =>
matches.map((queueItem) => (
<SelectItem
key={queueItem.value}
value={queueItem.value}
className="max-w-(--popover-anchor-width)"
icon={
queueItem.type === "task" ? (
<TaskIcon className="size-4 shrink-0 text-blue-500" />
) : (
<RectangleStackIcon className="size-4 shrink-0 text-purple-500" />
)
}
>
<div className="flex w-full min-w-0 items-center justify-between">
<span className="truncate">{queueItem.label}</span>
{queueItem.paused && (
<Badge variant="extra-small" className="ml-1 text-warning">
Paused
</Badge>
)}
</div>
</SelectItem>
))
}
</Select>
)}
<Hint>Assign run to a specific queue.</Hint>
<FormError id={queue.errorId}>{queue.errors}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={tags.id} variant="small">
Tags
</Label>
<RunTagInput
name={tags.name}
id={tags.id}
variant="small"
tags={tagsValue}
onTagsChange={setTagsValue}
/>
<Hint>Add tags to easily filter runs.</Hint>
<FormError id={tags.errorId}>{tags.errors}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={maxAttempts.id} variant="small">
Max attempts
</Label>
<Input
{...getInputProps(maxAttempts, { type: "number" })}
className="[&::-webkit-inner-spin-button]:appearance-none"
variant="small"
min={1}
value={maxAttemptsValue}
onChange={(e) =>
setMaxAttemptsValue(e.target.value ? parseInt(e.target.value) : undefined)
}
onKeyDown={(e) => {
// only allow entering integers > 1
if (["-", "+", ".", "e", "E"].includes(e.key)) {
e.preventDefault();
}
}}
onBlur={(e) => {
const value = parseInt(e.target.value);
if (value < 1 && e.target.value !== "") {
e.target.value = "1";
}
}}
/>
<Hint>Retries failed runs up to the specified number of attempts.</Hint>
<FormError id={maxAttempts.errorId}>{maxAttempts.errors}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={maxDurationSeconds.id} variant="small">
Max duration
</Label>
<DurationPicker
name={maxDurationSeconds.name}
id={maxDurationSeconds.id}
value={maxDurationValue}
onChange={setMaxDurationValue}
/>
<Hint>Overrides the maximum compute time limit for the run.</Hint>
<FormError id={maxDurationSeconds.errorId}>{maxDurationSeconds.errors}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={idempotencyKey.id} variant="small">
Idempotency key
</Label>
<Input {...getInputProps(idempotencyKey, { type: "text" })} variant="small" />
<FormError id={idempotencyKey.errorId}>{idempotencyKey.errors}</FormError>
<Hint>
Specify an idempotency key to ensure that a task is only triggered once with the
same key.
</Hint>
</InputGroup>
<InputGroup>
<Label htmlFor={idempotencyKeyTTLSeconds.id} variant="small">
Idempotency key TTL
</Label>
<DurationPicker
name={idempotencyKeyTTLSeconds.name}
id={idempotencyKeyTTLSeconds.id}
/>
<Hint>Keys expire after 30 days by default.</Hint>
<FormError id={idempotencyKeyTTLSeconds.errorId}>
{idempotencyKeyTTLSeconds.errors}
</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={concurrencyKey.id} variant="small">
Concurrency key
</Label>
<Input
{...getInputProps(concurrencyKey, { type: "text" })}
variant="small"
value={concurrencyKeyValue ?? ""}
onChange={(e) => setConcurrencyKeyValue(e.target.value)}
/>
<Hint>
Limits concurrency by creating a separate queue for each value of the key.
</Hint>
<FormError id={concurrencyKey.errorId}>{concurrencyKey.errors}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={prioritySeconds.id} variant="small">
Priority
</Label>
<DurationPicker name={prioritySeconds.name} id={prioritySeconds.id} />
<Hint>Sets the priority of the run. Higher values mean higher priority.</Hint>
<FormError id={prioritySeconds.errorId}>{prioritySeconds.errors}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={ttlSeconds.id} variant="small">
TTL
</Label>
<DurationPicker
name={ttlSeconds.name}
id={ttlSeconds.id}
value={ttlValue}
onChange={setTtlValue}
/>
<Hint>Expires the run if it hasn't started within the TTL.</Hint>
<FormError id={ttlSeconds.errorId}>{ttlSeconds.errors}</FormError>
</InputGroup>
</Fieldset>
</div>
</div>
{/* Toolbar overlay — same grid cell, sits above scrolling form. Outer
layer disables pointer events so the form scrolls through; the
inner buttons re-enable them. */}
<div className="pointer-events-none col-start-1 row-start-1 z-10 flex items-start justify-end p-2">
<div className="pointer-events-auto flex items-center gap-1.5">
<RunTemplatesPopover
templates={templates}
onTemplateSelected={(template) => {
setTtlValue(template.ttlSeconds ?? 0);
setConcurrencyKeyValue(template.concurrencyKey ?? "");
setMaxAttemptsValue(template.maxAttempts ?? undefined);
setMaxDurationValue(template.maxDurationSeconds ?? 0);
setMachineValue(template.machinePreset ?? undefined);
setTagsValue(template.tags ?? []);
setQueueValue(template.queue ?? undefined);
setTimestampValue(template.scheduledTaskPayload?.timestamp);
setLastTimestampValue(template.scheduledTaskPayload?.lastTimestamp);
setExternalIdValue(template.scheduledTaskPayload?.externalId);
setTimezoneValue(template.scheduledTaskPayload?.timezone ?? "UTC");
}}
showTemplateCreatedSuccessMessage={showTemplateCreatedSuccessMessage}
/>
<RecentRunsPopover
runs={runs}
onRunSelected={(run) => {
setTimestampValue(run.payload.timestamp);
setLastTimestampValue(run.payload.lastTimestamp);
setExternalIdValue(run.payload.externalId);
setTimezoneValue(run.payload.timezone);
setTtlValue(run.ttlSeconds);
setConcurrencyKeyValue(run.concurrencyKey);
setMaxAttemptsValue(run.maxAttempts);
setMaxDurationValue(run.maxDurationInSeconds);
setTagsValue(run.runTags ?? []);
setQueueValue(run.queue);
setMachineValue(run.machinePreset ?? undefined);
}}
/>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 border-t border-grid-bright bg-background-dimmed p-2">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<Paragraph variant="small" className="whitespace-nowrap">
This test will run in
</Paragraph>
<EnvironmentCombo environment={environment} className="gap-0.5" />
</div>
<CreateTemplateModal
rawTestTaskFormData={{
environmentId: environment.id,
taskIdentifier: task.taskIdentifier,
triggerSource: "SCHEDULED",
ttlSeconds: ttlValue?.toString(),
queue: queueValue,
concurrencyKey: concurrencyKeyValue,
maxAttempts: maxAttemptsValue?.toString(),
maxDurationSeconds: maxDurationValue?.toString(),
tags: tagsValue.join(","),
machine: machineValue,
timestamp: timestampValue?.toISOString(),
lastTimestamp: lastTimestampValue?.toISOString(),
timezone: timezoneValue,
externalId: externalIdValue,
}}
getCurrentPayload={() => ""}
getCurrentMetadata={() => ""}
setShowCreatedSuccessMessage={setShowTemplateCreatedSuccessMessage}
/>
<Button
type="submit"
variant="primary/medium"
LeadingIcon={BeakerIcon}
iconSpacing="gap-x-2.5"
leadingIconClassName="-mx-2"
shortcut={{ key: "enter", modifiers: ["mod"], enabledOnInputElements: true }}
name="formAction"
value={"run-scheduled" satisfies FormAction}
>
Run test
</Button>
</div>
</div>
</Form>
);
}
function RecentRunsPopover<T extends StandardRun | ScheduledRun>({
runs,
onRunSelected,
}: {
runs: T[];
onRunSelected: (run: T) => void;
}) {
const [isRecentRunsPopoverOpen, setIsRecentRunsPopoverOpen] = useState(false);
return (
<Popover open={isRecentRunsPopoverOpen} onOpenChange={setIsRecentRunsPopoverOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="tertiary/small"
LeadingIcon={ClockRotateLeftIcon}
disabled={runs.length === 0}
>
Recent runs
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-[294px] p-0" align="end" sideOffset={6}>
<div className="max-h-80 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="p-1">
{runs.map((run) => (
<button
key={run.id}
type="button"
onClick={() => {
onRunSelected(run);
setIsRecentRunsPopoverOpen(false);
}}
className="flex w-full items-center gap-2 rounded-sm px-2 py-2 outline-hidden transition-colors focus-custom hover:bg-background-deep "
>
<div className="flex flex-col items-start">
<Paragraph variant="small/bright">
<DateTime date={run.createdAt} showTooltip={false} />
</Paragraph>
<div className="flex items-center gap-2 text-xs text-text-dimmed">
<div>
Run <span className="font-mono">{run.friendlyId.slice(-8)}</span>
</div>
<TaskRunStatusCombo status={run.status} />
</div>
</div>
</button>
))}
</div>
</div>
</PopoverContent>
</Popover>
);
}
function RunTemplatesPopover({
templates,
onTemplateSelected,
showTemplateCreatedSuccessMessage,
}: {
templates: RunTemplate[];
onTemplateSelected: (run: RunTemplate) => void;
showTemplateCreatedSuccessMessage: boolean;
}) {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [templateIdToDelete, setTemplateIdToDelete] = useState<string | undefined>();
const actionData = useActionData<typeof action>();
const lastSubmission =
actionData &&
typeof actionData === "object" &&
"formAction" in actionData &&
actionData.formAction === ("delete-template" satisfies FormAction)
? actionData
: undefined;
useEffect(() => {
if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) {
setIsDeleteDialogOpen(false);
}
}, [lastSubmission]);
const [deleteForm, { templateId }] = useForm({
id: "delete-template",
onValidate({ formData }) {
return parseWithZod(formData, { schema: DeleteTaskRunTemplateData });
},
});
return (
<div className="relative">
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="tertiary/small"
LeadingIcon={StarIcon}
disabled={templates.length === 0}
>
Templates
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-[279px] p-0" align="end" sideOffset={6}>
<div className="max-h-80 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="p-1">
{templates.map((template) => (
<div
key={template.id}
className="group flex w-full items-center gap-2 rounded-sm px-2 py-2 outline-hidden transition-colors hover:bg-background-deep"
>
<button
type="button"
onClick={() => {
onTemplateSelected(template);
setIsPopoverOpen(false);
}}
className="flex-1 text-left outline-hidden focus-custom"
>
<div className="flex flex-col items-start">
<Paragraph variant="small/bright" className="truncate">
{template.label}
</Paragraph>
<div className="flex items-center gap-2 text-xs text-text-dimmed">
<DateTime
date={template.createdAt}
showTooltip={false}
includeTime={false}
/>
</div>
</div>
</button>
<Button
type="button"
className="group/delete-template shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
variant="minimal/medium"
LeadingIcon={TrashIcon}
leadingIconClassName="group-hover/delete-template:text-error"
onClick={() => {
setTemplateIdToDelete(template.id);
setIsDeleteDialogOpen(true);
setIsPopoverOpen(false);
}}
/>
</div>
))}
</div>
</div>
</PopoverContent>
</Popover>
<AnimatePresence mode="wait">
{showTemplateCreatedSuccessMessage && (
<motion.div
key="template-success-message"
initial={{
opacity: 0,
scale: 0.8,
y: -10,
}}
animate={{
opacity: 1,
scale: 1,
y: 0,
}}
exit={{
opacity: 0,
scale: 0.7,
y: -10,
transition: {
duration: 0.15,
ease: "easeOut",
},
}}
transition={{
type: "spring",
stiffness: 400,
damping: 25,
duration: 0.15,
}}
className="absolute -left-1/2 top-full z-10 mt-1 flex min-w-max max-w-64 items-center gap-1 rounded border border-grid-bright bg-background-bright px-2 py-1 text-xs shadow-md outline-hidden before:absolute before:-top-2 before:left-1/2 before:-translate-x-1/2 before:border-4 before:border-transparent before:border-b-grid-bright before:content-[''] after:absolute after:top-[-7px] after:left-1/2 after:-translate-x-1/2 after:border-4 after:border-transparent after:border-b-background-bright after:content-['']"
>
<CheckCircleIcon className="h-4 w-4 shrink-0 text-success" /> Template saved
successfully
</motion.div>
)}
</AnimatePresence>
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>Delete template</DialogHeader>
<DialogDescription className="mt-3">
Are you sure you want to delete the template? This can't be reversed.
</DialogDescription>
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="tertiary/medium"
onClick={() => setIsDeleteDialogOpen(false)}
>
Cancel
</Button>
<Form method="post" {...getFormProps(deleteForm)}>
<input
{...getInputProps(templateId, { type: "hidden" })}
value={templateIdToDelete || ""}
/>
<Button
type="submit"
variant="danger/medium"
LeadingIcon={TrashIcon}
name="formAction"
value={"delete-template" satisfies FormAction}
>
Delete
</Button>
</Form>
</div>
</DialogContent>
</Dialog>
</div>
);
}
function CreateTemplateModal({
rawTestTaskFormData,
getCurrentPayload,
getCurrentMetadata,
setShowCreatedSuccessMessage,
}: {
rawTestTaskFormData: {
environmentId: string;
taskIdentifier: string;
triggerSource: string;
delaySeconds?: string;
ttlSeconds?: string;
queue?: string;
concurrencyKey?: string;
maxAttempts?: string;
maxDurationSeconds?: string;
tags?: string;
machine?: string;
externalId?: string;
timestamp?: string;
timezone?: string;
lastTimestamp?: string;
};
getCurrentPayload: () => string;
getCurrentMetadata: () => string;
setShowCreatedSuccessMessage: (value: boolean) => void;
}) {
const submit = useSubmit();
const [isModalOpen, setIsModalOpen] = useState(false);
const actionData = useActionData<typeof action>();
const lastSubmission =
actionData &&
typeof actionData === "object" &&
"formAction" in actionData &&
actionData.formAction === ("create-template" satisfies FormAction)
? actionData
: undefined;
useEffect(() => {
if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) {
setIsModalOpen(false);
setShowCreatedSuccessMessage(true);
setTimeout(() => {
setShowCreatedSuccessMessage(false);
}, 2000);
}
}, [lastSubmission]);
const [
form,
{
label,
environmentId,
payload,
metadata,
taskIdentifier,
delaySeconds,
ttlSeconds,
queue,
concurrencyKey,
maxAttempts,
maxDurationSeconds,
triggerSource,
tags,
machine,
externalId,
timestamp,
lastTimestamp,
timezone,
},
] = useForm({
id: "save-template",
lastResult: lastSubmission as any,
onSubmit(event, { formData }) {
event.preventDefault();
formData.set(payload.name, getCurrentPayload());
formData.set(metadata.name, getCurrentMetadata());
submit(formData, { method: "POST" });
},
onValidate({ formData }) {
return parseWithZod(formData, { schema: RunTemplateData });
},
shouldRevalidate: "onInput",
});
return (
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogTrigger asChild>
<Button
type="button"
variant="tertiary/medium"
LeadingIcon={StarIcon}
tooltip="Create run template"
/>
</DialogTrigger>
<DialogContent>
<DialogHeader>Create run template</DialogHeader>
<div className="mt-2 flex flex-col gap-4">
<Form method="post" {...getFormProps(form)} className="w-full">
<input
{...getInputProps(taskIdentifier, { type: "hidden" })}
value={rawTestTaskFormData.taskIdentifier}
/>
<input
{...getInputProps(environmentId, { type: "hidden" })}
value={rawTestTaskFormData.environmentId}
/>
<input
{...getInputProps(triggerSource, { type: "hidden" })}
value={rawTestTaskFormData.triggerSource}
/>
<input
{...getInputProps(delaySeconds, { type: "hidden" })}
value={rawTestTaskFormData.delaySeconds}
/>
<input
{...getInputProps(ttlSeconds, { type: "hidden" })}
value={rawTestTaskFormData.ttlSeconds}
/>
<input
{...getInputProps(queue, { type: "hidden" })}
value={rawTestTaskFormData.queue}
/>
<input
{...getInputProps(concurrencyKey, { type: "hidden" })}
value={rawTestTaskFormData.concurrencyKey}
/>
<input
{...getInputProps(maxAttempts, { type: "hidden" })}
value={rawTestTaskFormData.maxAttempts}
/>
<input
{...getInputProps(maxDurationSeconds, { type: "hidden" })}
value={rawTestTaskFormData.maxDurationSeconds}
/>
<input {...getInputProps(tags, { type: "hidden" })} value={rawTestTaskFormData.tags} />
<input
{...getInputProps(machine, { type: "hidden" })}
value={rawTestTaskFormData.machine}
/>
<input
{...getInputProps(externalId, { type: "hidden" })}
value={rawTestTaskFormData.externalId}
/>
<input
{...getInputProps(timestamp, { type: "hidden" })}
value={rawTestTaskFormData.timestamp}
/>
<input
{...getInputProps(lastTimestamp, { type: "hidden" })}
value={rawTestTaskFormData.lastTimestamp}
/>
<input
{...getInputProps(timezone, { type: "hidden" })}
value={rawTestTaskFormData.timezone}
/>
<Paragraph className="mb-3">
Save your current run configuration as a template to reuse it later. Templates can be
used across environments.
</Paragraph>
<Fieldset className="max-w-full gap-y-3">
<InputGroup className="max-w-full">
<Label htmlFor={label.id}>Template label</Label>
<Input
{...getInputProps(label, { type: "text" })}
placeholder="Enter a name for this template"
maxLength={42}
/>
<FormError id={label.errorId}>{label.errors}</FormError>
</InputGroup>
<FormError>{form.errors}</FormError>
<FormButtons
confirmButton={
<Button
type="submit"
variant="primary/medium"
name="formAction"
value={"create-template" satisfies FormAction}
>
Create template
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</Fieldset>
</Form>
</div>
</DialogContent>
</Dialog>
);
}