Skip to content

Commit 7cf0643

Browse files
committed
Add modal to create run templates in the test page
1 parent de880f6 commit 7cf0643

1 file changed

Lines changed: 260 additions & 8 deletions

File tree

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx

Lines changed: 260 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { conform, useForm } from "@conform-to/react";
22
import { parse } from "@conform-to/zod";
3-
import { BeakerIcon, RectangleStackIcon } from "@heroicons/react/20/solid";
3+
import { BeakerIcon, StarIcon, RectangleStackIcon } from "@heroicons/react/20/solid";
44
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
55
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
66
import { typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -60,6 +60,11 @@ import { ClockRotateLeftIcon } from "~/assets/icons/ClockRotateLeftIcon";
6060
import { MachinePresetName } from "@trigger.dev/core/v3";
6161
import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource";
6262
import { 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

6469
export 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

Comments
 (0)