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(); if (!result.foundTask) { return
; } const params = useParams(); const queueFetcher = useFetcher(); 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 ( ); } case "SCHEDULED": { return ( ); } 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["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(); 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( lastRun?.payload ?? startingJson ); const setPayload = useCallback((code: string) => { setDefaultPayloadJson(code); }, []); const currentPayloadJson = useRef(defaultPayloadJson); const [defaultMetadataJson, setDefaultMetadataJson] = useState( lastRun?.seedMetadata ?? startingJson ); const setMetadata = useCallback((code: string) => { setDefaultMetadataJson(code); }, []); const currentMetadataJson = useRef(defaultMetadataJson); const [ttlValue, setTtlValue] = useState(lastRun?.ttlSeconds); const [concurrencyKeyValue, setConcurrencyKeyValue] = useState( lastRun?.concurrencyKey ); const [queueValue, setQueueValue] = useState(lastRun?.queue); const [machineValue, setMachineValue] = useState(lastRun?.machinePreset); const isDev = environment.type === "DEVELOPMENT"; const defaultRegion = regions.find((r) => r.isDefault); const [regionValue, setRegionValue] = useState( isDev ? undefined : defaultRegion?.name ); const [maxAttemptsValue, setMaxAttemptsValue] = useState( lastRun?.maxAttempts ); const [maxDurationValue, setMaxDurationValue] = useState( lastRun?.maxDurationInSeconds ); const [tagsValue, setTagsValue] = useState(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 (
{task.taskIdentifier}
{ 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} /> { 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); }} />
{ 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={
{ replace({ tab: "payload" }); }} > Payload { replace({ tab: "metadata" }); }} > Metadata
} />
Options enable you to control the execution behavior of your task.{" "} Read the docs. Overrides the machine preset. {machine.errors} {disableVersionSelection ? ( Only the latest version is available in the development environment. ) : ( Runs task on a specific version. )} {version.errors} {regionItems.length > 1 && ( {/* 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. */} {isDev ? ( Region is not available in the development environment. ) : ( Overrides the region for this run. )} {region.errors} )} {allowArbitraryQueues ? ( setQueueValue(e.target.value)} /> ) : ( )} Assign run to a specific queue. {queue.errors} Add tags to easily filter runs. {tags.errors} 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"; } }} /> Retries failed runs up to the specified number of attempts. {maxAttempts.errors} Overrides the maximum compute time limit for the run. {maxDurationSeconds.errors} {idempotencyKey.errors} Specify an idempotency key to ensure that a task is only triggered once with the same key. Keys expire after 30 days by default. {idempotencyKeyTTLSeconds.errors} setConcurrencyKeyValue(e.target.value)} /> Limits concurrency by creating a separate queue for each value of the key. {concurrencyKey.errors} Delays run by a specific duration. {delaySeconds.errors} Sets the priority of the run. Higher values mean higher priority. {prioritySeconds.errors} Expires the run if it hasn't started within the TTL. {ttlSeconds.errors} {form.errors} } aiContent={ currentPayloadJson.current} /> } schemaContent={ } />
This test will run in
currentPayloadJson.current} getCurrentMetadata={() => currentMetadataJson.current} setShowCreatedSuccessMessage={setShowTemplateCreatedSuccessMessage} />
); } function ScheduledTaskForm({ task, runs, possibleTimezones, queues, versions, templates, disableVersionSelection, allowArbitraryQueues, regions, }: { task: ScheduledTaskResult["task"]; runs: ScheduledRun[]; possibleTimezones: string[]; queues: Required["queue"][]; versions: string[]; templates: RunTemplate[]; disableVersionSelection: boolean; allowArbitraryQueues: boolean; regions: Region[]; }) { const environment = useEnvironment(); const lastRun = runs.at(0); const [timestampValue, setTimestampValue] = useState( lastRun?.payload?.timestamp ?? new Date() ); const [lastTimestampValue, setLastTimestampValue] = useState( lastRun?.payload?.lastTimestamp ); const [externalIdValue, setExternalIdValue] = useState( lastRun?.payload?.externalId ); const [timezoneValue, setTimezoneValue] = useState(lastRun?.payload?.timezone ?? "UTC"); const [ttlValue, setTtlValue] = useState(lastRun?.ttlSeconds); const [concurrencyKeyValue, setConcurrencyKeyValue] = useState( lastRun?.concurrencyKey ); const [queueValue, setQueueValue] = useState(lastRun?.queue); const [machineValue, setMachineValue] = useState(lastRun?.machinePreset); const isDev = environment.type === "DEVELOPMENT"; const defaultRegion = regions.find((r) => r.isDefault); const [regionValue, setRegionValue] = useState( isDev ? undefined : defaultRegion?.name ); const [maxAttemptsValue, setMaxAttemptsValue] = useState( lastRun?.maxAttempts ); const [maxDurationValue, setMaxDurationValue] = useState( lastRun?.maxDurationInSeconds ); const [tagsValue, setTagsValue] = useState(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(); 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 (
{/* Main area: scrolling form with the toolbar floating on top-right in the same grid cell */}
{task.taskIdentifier}
setTimestampValue(val)} granularity="second" showNowButton variant="small" utc /> This is the timestamp of the CRON, it will come through to your run in the payload. {timestamp.errors} setLastTimestampValue(val)} granularity="second" showNowButton showClearButton variant="small" utc /> This is the timestamp of the previous run. You can use this in your code to find new data since the previous run. {lastTimestamp.errors} The Timestamp and Last timestamp are in UTC so this just changes the timezone string that comes through in the payload. {timezone.errors} setExternalIdValue(e.target.value)} variant="small" /> Optionally, you can specify your own IDs (like a user ID) and then use it inside the run function of your task.{" "} Read the docs. {externalId.errors}
Options enable you to control the execution behavior of your task.{" "} Read the docs. Overrides the machine preset. {machine.errors} {disableVersionSelection ? ( Only the latest version is available in the development environment. ) : ( Runs task on a specific version. )} {version.errors} {regionItems.length > 1 && ( {/* 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. */} {isDev ? ( Region is not available in the development environment. ) : ( Overrides the region for this run. )} {region.errors} )} {allowArbitraryQueues ? ( setQueueValue(e.target.value)} /> ) : ( )} Assign run to a specific queue. {queue.errors} Add tags to easily filter runs. {tags.errors} 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"; } }} /> Retries failed runs up to the specified number of attempts. {maxAttempts.errors} Overrides the maximum compute time limit for the run. {maxDurationSeconds.errors} {idempotencyKey.errors} Specify an idempotency key to ensure that a task is only triggered once with the same key. Keys expire after 30 days by default. {idempotencyKeyTTLSeconds.errors} setConcurrencyKeyValue(e.target.value)} /> Limits concurrency by creating a separate queue for each value of the key. {concurrencyKey.errors} Sets the priority of the run. Higher values mean higher priority. {prioritySeconds.errors} Expires the run if it hasn't started within the TTL. {ttlSeconds.errors}
{/* 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. */}
{ 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} /> { 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); }} />
This test will run in
""} getCurrentMetadata={() => ""} setShowCreatedSuccessMessage={setShowTemplateCreatedSuccessMessage} />
); } function RecentRunsPopover({ runs, onRunSelected, }: { runs: T[]; onRunSelected: (run: T) => void; }) { const [isRecentRunsPopoverOpen, setIsRecentRunsPopoverOpen] = useState(false); return (
{runs.map((run) => ( ))}
); } 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(); const actionData = useActionData(); 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 (
{templates.map((template) => (
))}
{showTemplateCreatedSuccessMessage && ( Template saved successfully )} Delete template Are you sure you want to delete the template? This can't be reversed.
); } 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(); 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 ( } cancelButton={ } /> ); }