1905 lines
72 KiB
TypeScript
1905 lines
72 KiB
TypeScript
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>
|
||
);
|
||
}
|