11import { conform , useForm } from "@conform-to/react" ;
22import { parse } from "@conform-to/zod" ;
3- import { BeakerIcon , RectangleStackIcon } from "@heroicons/react/20/solid" ;
3+ import { BeakerIcon , StarIcon , RectangleStackIcon } from "@heroicons/react/20/solid" ;
44import { type ActionFunction , type LoaderFunctionArgs , json } from "@remix-run/server-runtime" ;
55import { useCallback , useEffect , useRef , useState , useMemo } from "react" ;
66import { typedjson , useTypedLoaderData } from "remix-typedjson" ;
@@ -60,6 +60,11 @@ import { ClockRotateLeftIcon } from "~/assets/icons/ClockRotateLeftIcon";
6060import { MachinePresetName } from "@trigger.dev/core/v3" ;
6161import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource" ;
6262import { Callout } from "~/components/primitives/Callout" ;
63+ import { TaskRunTemplateService } from "~/v3/services/taskRunTemplate.server" ;
64+ import { RunTemplateData } from "~/v3/taskRunTemplate" ;
65+ import { Dialog , DialogContent , DialogHeader , DialogTrigger } from "~/components/primitives/Dialog" ;
66+ import { DialogClose } from "@radix-ui/react-dialog" ;
67+ import { FormButtons } from "~/components/primitives/FormButtons" ;
6368
6469export const loader = async ( { request, params } : LoaderFunctionArgs ) => {
6570 const userId = await requireUserId ( request ) ;
@@ -104,13 +109,6 @@ export const action: ActionFunction = async ({ request, params }) => {
104109 const userId = await requireUserId ( request ) ;
105110 const { organizationSlug, projectParam, envParam, taskParam } = v3TaskParamsSchema . parse ( params ) ;
106111
107- const formData = await request . formData ( ) ;
108- const submission = parse ( formData , { schema : TestTaskData } ) ;
109-
110- if ( ! submission . value ) {
111- return json ( submission ) ;
112- }
113-
114112 const project = await findProjectBySlug ( organizationSlug , projectParam , userId ) ;
115113 if ( ! project ) {
116114 return redirectBackWithErrorMessage ( request , "Project not found" ) ;
@@ -122,6 +120,36 @@ export const action: ActionFunction = async ({ request, params }) => {
122120 return redirectBackWithErrorMessage ( request , "Environment not found" ) ;
123121 }
124122
123+ const formData = await request . formData ( ) ;
124+ const formAction = formData . get ( "formAction" ) ;
125+
126+ // Handle run template creation
127+ if ( formAction === "create-template" ) {
128+ const runTemplateData = parse ( formData , { schema : RunTemplateData } ) ;
129+ if ( ! runTemplateData . value ) {
130+ return json ( runTemplateData ) ;
131+ }
132+
133+ const templateService = new TaskRunTemplateService ( ) ;
134+ try {
135+ await templateService . call ( environment , runTemplateData . value ) ;
136+
137+ return json ( {
138+ success : true ,
139+ message : `Template "${ runTemplateData . value . label } " created successfully` ,
140+ } ) ;
141+ } catch ( e ) {
142+ logger . error ( "Failed to create template" , { error : e instanceof Error ? e . message : e } ) ;
143+ return redirectBackWithErrorMessage ( request , "Failed to create template" ) ;
144+ }
145+ }
146+
147+ const submission = parse ( formData , { schema : TestTaskData } ) ;
148+
149+ if ( ! submission . value ) {
150+ return json ( submission ) ;
151+ }
152+
125153 if ( environment . archivedAt ) {
126154 return redirectBackWithErrorMessage ( request , "Can't run a test on an archived environment" ) ;
127155 }
@@ -645,6 +673,22 @@ function StandardTaskForm({
645673 </ Paragraph >
646674 < EnvironmentCombo environment = { environment } className = "gap-0.5" />
647675 </ div >
676+ < CreateTemplateModal
677+ rawTestTaskFormData = { {
678+ environmentId : environment . id ,
679+ taskIdentifier : task . taskIdentifier ,
680+ triggerSource : "STANDARD" ,
681+ ttlSeconds : ttlValue ?. toString ( ) ,
682+ queue : queueValue ,
683+ concurrencyKey : concurrencyKeyValue ,
684+ maxAttempts : maxAttemptsValue ?. toString ( ) ,
685+ maxDurationSeconds : maxDurationValue ?. toString ( ) ,
686+ tags : tagsValue . join ( "," ) ,
687+ machine : machineValue ,
688+ } }
689+ getCurrentPayload = { ( ) => currentPayloadJson . current }
690+ getCurrentMetadata = { ( ) => currentMetadataJson . current }
691+ />
648692 < Button
649693 type = "submit"
650694 variant = "primary/medium"
@@ -1102,6 +1146,26 @@ function ScheduledTaskForm({
11021146 </ Paragraph >
11031147 < EnvironmentCombo environment = { environment } className = "gap-0.5" />
11041148 </ div >
1149+ < CreateTemplateModal
1150+ rawTestTaskFormData = { {
1151+ environmentId : environment . id ,
1152+ taskIdentifier : task . taskIdentifier ,
1153+ triggerSource : "SCHEDULED" ,
1154+ ttlSeconds : ttlValue ?. toString ( ) ,
1155+ queue : queueValue ,
1156+ concurrencyKey : concurrencyKeyValue ,
1157+ maxAttempts : maxAttemptsValue ?. toString ( ) ,
1158+ maxDurationSeconds : maxDurationValue ?. toString ( ) ,
1159+ tags : tagsValue . join ( "," ) ,
1160+ machine : machineValue ,
1161+ timestamp : timestampValue ?. toISOString ( ) ,
1162+ lastTimestamp : lastTimestampValue ?. toISOString ( ) ,
1163+ timezone : timezoneValue ,
1164+ externalId : externalIdValue ,
1165+ } }
1166+ getCurrentPayload = { ( ) => "" }
1167+ getCurrentMetadata = { ( ) => "" }
1168+ />
11051169 < Button
11061170 type = "submit"
11071171 variant = "primary/medium"
@@ -1169,3 +1233,191 @@ function RecentRunsPopover<T extends StandardRun | ScheduledRun>({
11691233 </ Popover >
11701234 ) ;
11711235}
1236+
1237+ function CreateTemplateModal ( {
1238+ rawTestTaskFormData,
1239+ getCurrentPayload,
1240+ getCurrentMetadata,
1241+ } : {
1242+ rawTestTaskFormData : {
1243+ environmentId : string ;
1244+ taskIdentifier : string ;
1245+ triggerSource : string ;
1246+ delaySeconds ?: string ;
1247+ ttlSeconds ?: string ;
1248+ queue ?: string ;
1249+ concurrencyKey ?: string ;
1250+ maxAttempts ?: string ;
1251+ maxDurationSeconds ?: string ;
1252+ tags ?: string ;
1253+ machine ?: string ;
1254+ externalId ?: string ;
1255+ timestamp ?: string ;
1256+ timezone ?: string ;
1257+ lastTimestamp ?: string ;
1258+ } ;
1259+ getCurrentPayload : ( ) => string ;
1260+ getCurrentMetadata : ( ) => string ;
1261+ } ) {
1262+ const lastSubmission = useActionData < typeof action > ( ) ;
1263+
1264+ const fetcher = useFetcher ( ) ;
1265+ const [ isModalOpen , setIsModalOpen ] = useState ( false ) ;
1266+
1267+ useEffect ( ( ) => {
1268+ if (
1269+ fetcher . state === "idle" &&
1270+ fetcher . data &&
1271+ typeof fetcher . data === "object" &&
1272+ "success" in fetcher . data &&
1273+ fetcher . data . success
1274+ ) {
1275+ setIsModalOpen ( false ) ;
1276+ }
1277+ } , [ fetcher . state , fetcher . data ] ) ;
1278+
1279+ const [
1280+ form ,
1281+ {
1282+ label,
1283+ environmentId,
1284+ payload,
1285+ metadata,
1286+ taskIdentifier,
1287+ delaySeconds,
1288+ ttlSeconds,
1289+ queue,
1290+ concurrencyKey,
1291+ maxAttempts,
1292+ maxDurationSeconds,
1293+ triggerSource,
1294+ tags,
1295+ machine,
1296+ externalId,
1297+ timestamp,
1298+ lastTimestamp,
1299+ timezone,
1300+ } ,
1301+ ] = useForm ( {
1302+ id : "save-template" ,
1303+ lastSubmission : lastSubmission as any ,
1304+ onSubmit ( event , { formData } ) {
1305+ event . preventDefault ( ) ;
1306+
1307+ formData . set ( payload . name , getCurrentPayload ( ) ) ;
1308+ formData . set ( metadata . name , getCurrentMetadata ( ) ) ;
1309+
1310+ fetcher . submit ( formData , { method : "POST" } ) ;
1311+ } ,
1312+ onValidate ( { formData } ) {
1313+ return parse ( formData , { schema : RunTemplateData } ) ;
1314+ } ,
1315+ shouldRevalidate : "onInput" ,
1316+ } ) ;
1317+
1318+ return (
1319+ < Dialog open = { isModalOpen } onOpenChange = { setIsModalOpen } >
1320+ < DialogTrigger asChild >
1321+ < Button
1322+ type = "button"
1323+ variant = "tertiary/medium"
1324+ LeadingIcon = { StarIcon }
1325+ tooltip = "Create run template"
1326+ />
1327+ </ DialogTrigger >
1328+ < DialogContent >
1329+ < DialogHeader > Create run template</ DialogHeader >
1330+ < div className = "mt-2 flex flex-col gap-4" >
1331+ < Form method = "post" { ...form . props } className = "w-full" >
1332+ < input
1333+ { ...conform . input ( taskIdentifier , { type : "hidden" } ) }
1334+ value = { rawTestTaskFormData . taskIdentifier }
1335+ />
1336+ < input
1337+ { ...conform . input ( environmentId , { type : "hidden" } ) }
1338+ value = { rawTestTaskFormData . environmentId }
1339+ />
1340+ < input
1341+ { ...conform . input ( triggerSource , { type : "hidden" } ) }
1342+ value = { rawTestTaskFormData . triggerSource }
1343+ />
1344+ < input
1345+ { ...conform . input ( delaySeconds , { type : "hidden" } ) }
1346+ value = { rawTestTaskFormData . delaySeconds }
1347+ />
1348+ < input
1349+ { ...conform . input ( ttlSeconds , { type : "hidden" } ) }
1350+ value = { rawTestTaskFormData . ttlSeconds }
1351+ />
1352+ < input
1353+ { ...conform . input ( queue , { type : "hidden" } ) }
1354+ value = { rawTestTaskFormData . queue }
1355+ />
1356+ < input
1357+ { ...conform . input ( concurrencyKey , { type : "hidden" } ) }
1358+ value = { rawTestTaskFormData . concurrencyKey }
1359+ />
1360+ < input
1361+ { ...conform . input ( maxAttempts , { type : "hidden" } ) }
1362+ value = { rawTestTaskFormData . maxAttempts }
1363+ />
1364+ < input
1365+ { ...conform . input ( maxDurationSeconds , { type : "hidden" } ) }
1366+ value = { rawTestTaskFormData . maxDurationSeconds }
1367+ />
1368+ < input { ...conform . input ( tags , { type : "hidden" } ) } value = { rawTestTaskFormData . tags } />
1369+ < input
1370+ { ...conform . input ( machine , { type : "hidden" } ) }
1371+ value = { rawTestTaskFormData . machine }
1372+ />
1373+ < input
1374+ { ...conform . input ( externalId , { type : "hidden" } ) }
1375+ value = { rawTestTaskFormData . externalId }
1376+ />
1377+ < input
1378+ { ...conform . input ( timestamp , { type : "hidden" } ) }
1379+ value = { rawTestTaskFormData . timestamp }
1380+ />
1381+ < input
1382+ { ...conform . input ( lastTimestamp , { type : "hidden" } ) }
1383+ value = { rawTestTaskFormData . lastTimestamp }
1384+ />
1385+ < input
1386+ { ...conform . input ( timezone , { type : "hidden" } ) }
1387+ value = { rawTestTaskFormData . timezone }
1388+ />
1389+ < Paragraph className = "mb-3" >
1390+ Save your current run configuration as a template to reuse it later. Templates can be
1391+ used across environments.
1392+ </ Paragraph >
1393+ < Fieldset className = "max-w-full gap-y-3" >
1394+ < InputGroup className = "max-w-full" >
1395+ < Label > Template label</ Label >
1396+ < Input { ...conform . input ( label ) } placeholder = "Enter a name for this template" />
1397+ < FormError id = { label . errorId } > { label . error } </ FormError >
1398+ </ InputGroup >
1399+ < FormError > { form . error } </ FormError >
1400+ < FormButtons
1401+ confirmButton = {
1402+ < Button
1403+ type = "submit"
1404+ variant = "primary/medium"
1405+ name = "formAction"
1406+ value = "create-template"
1407+ >
1408+ Create template
1409+ </ Button >
1410+ }
1411+ cancelButton = {
1412+ < DialogClose asChild >
1413+ < Button variant = "tertiary/medium" > Cancel</ Button >
1414+ </ DialogClose >
1415+ }
1416+ />
1417+ </ Fieldset >
1418+ </ Form >
1419+ </ div >
1420+ </ DialogContent >
1421+ </ Dialog >
1422+ ) ;
1423+ }
0 commit comments