From 77dfb27e200f2d8abaeabb863845fa7552796455 Mon Sep 17 00:00:00 2001 From: myftija Date: Mon, 7 Jul 2025 16:35:37 +0200 Subject: [PATCH 01/13] Add new prisma model for task run templates --- .../migration.sql | 23 ++++++++++++++++ .../database/prisma/schema.prisma | 27 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 internal-packages/database/prisma/migrations/20250707142736_add_task_run_template_model/migration.sql diff --git a/internal-packages/database/prisma/migrations/20250707142736_add_task_run_template_model/migration.sql b/internal-packages/database/prisma/migrations/20250707142736_add_task_run_template_model/migration.sql new file mode 100644 index 0000000000..33cdc4848f --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250707142736_add_task_run_template_model/migration.sql @@ -0,0 +1,23 @@ +CREATE TABLE "TaskRunTemplate" ( + "id" TEXT NOT NULL, + "taskSlug" TEXT NOT NULL, + "label" TEXT NOT NULL, + "payload" TEXT, + "payloadType" TEXT NOT NULL DEFAULT 'application/json', + "metadata" TEXT, + "metadataType" TEXT NOT NULL DEFAULT 'application/json', + "queue" TEXT NOT NULL, + "delaySeconds" INTEGER, + "maxAttempts" INTEGER, + "maxDurationSeconds" INTEGER, + "tags" TEXT[], + "machinePreset" TEXT, + "projectId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TaskRunTemplate_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "TaskRunTemplate_projectId_taskSlug_createdAt_idx" ON "TaskRunTemplate"("projectId", "taskSlug", "createdAt" DESC); \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 86bcf07f48..8298120a20 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -716,6 +716,33 @@ model TaskRun { @@index([status, runtimeEnvironmentId, createdAt, id(sort: Desc)]) } +model TaskRunTemplate { + id String @id @default(cuid()) + + taskSlug String + + label String + + payload String? + payloadType String @default("application/json") + metadata String? + metadataType String @default("application/json") + queue String + delaySeconds Int? + maxAttempts Int? + maxDurationSeconds Int? + tags String[] + machinePreset String? + + projectId String + organizationId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([projectId, taskSlug, createdAt(sort: Desc)]) +} + enum TaskRunStatus { /// /// NON-FINAL STATUSES From de880f6cf193ad921973b6981cdcf06b55846711 Mon Sep 17 00:00:00 2001 From: myftija Date: Mon, 7 Jul 2025 16:37:40 +0200 Subject: [PATCH 02/13] Create run templates in a new service --- .../app/v3/services/taskRunTemplate.server.ts | 88 +++++++++++++++++++ .../webapp/app/v3/services/testTask.server.ts | 7 +- apps/webapp/app/v3/taskRunTemplate.ts | 10 +++ 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 apps/webapp/app/v3/services/taskRunTemplate.server.ts create mode 100644 apps/webapp/app/v3/taskRunTemplate.ts diff --git a/apps/webapp/app/v3/services/taskRunTemplate.server.ts b/apps/webapp/app/v3/services/taskRunTemplate.server.ts new file mode 100644 index 0000000000..92f5bd2809 --- /dev/null +++ b/apps/webapp/app/v3/services/taskRunTemplate.server.ts @@ -0,0 +1,88 @@ +import { packetRequiresOffloading, stringifyIO } from "@trigger.dev/core/v3"; +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { BaseService } from "./baseService.server"; +import { env } from "~/env.server"; +import { handleMetadataPacket } from "~/utils/packets"; +import { type RunTemplateData } from "../taskRunTemplate"; + +export class TaskRunTemplateService extends BaseService { + public async call(environment: AuthenticatedEnvironment, data: RunTemplateData) { + const { triggerSource } = data; + + switch (triggerSource) { + case "STANDARD": { + const packet = { data: JSON.stringify(data.payload), dataType: "application/json" }; + + const { needsOffloading } = packetRequiresOffloading( + packet, + env.TASK_PAYLOAD_OFFLOAD_THRESHOLD + ); + + if (needsOffloading) { + // we currently disallow large payloads in task run templates + throw new Error("Payload too large"); + } + + const metadataPacket = data.metadata + ? handleMetadataPacket(data.metadata, "application/json") + : undefined; + + const taskRunTemplate = await this._prisma.taskRunTemplate.create({ + data: { + taskSlug: data.taskIdentifier, + label: data.label, + payload: packet.data, + payloadType: packet.dataType, + metadata: metadataPacket?.data, + metadataType: metadataPacket?.dataType, + queue: data.queue ?? "default", + delaySeconds: data.delaySeconds, + maxAttempts: data.maxAttempts, + maxDurationSeconds: data.maxDurationSeconds, + tags: data.tags ?? [], + machinePreset: data.machine, + projectId: environment.projectId, + organizationId: environment.organizationId, + }, + }); + + return taskRunTemplate; + } + case "SCHEDULED": { + const payload = { + scheduleId: "sched_1234", + type: "IMPERATIVE", + timestamp: data.timestamp, + lastTimestamp: data.lastTimestamp, + timezone: data.timezone, + externalId: data.externalId, + upcoming: [], + }; + const payloadPacket = await stringifyIO(payload); + + const taskRunTemplate = await this._prisma.taskRunTemplate.create({ + data: { + taskSlug: data.taskIdentifier, + label: data.label, + payload: payloadPacket.data, + payloadType: payloadPacket.dataType, + queue: data.queue ?? "default", + delaySeconds: data.delaySeconds, + maxAttempts: data.maxAttempts, + maxDurationSeconds: data.maxDurationSeconds, + tags: data.tags ?? [], + machinePreset: data.machine, + projectId: environment.projectId, + organizationId: environment.organizationId, + }, + }); + + return taskRunTemplate; + } + default: { + triggerSource satisfies never; + throw new Error("Invalid trigger source"); + } + } + } +} diff --git a/apps/webapp/app/v3/services/testTask.server.ts b/apps/webapp/app/v3/services/testTask.server.ts index b6e6410743..e2e52078b4 100644 --- a/apps/webapp/app/v3/services/testTask.server.ts +++ b/apps/webapp/app/v3/services/testTask.server.ts @@ -7,8 +7,9 @@ import { TriggerTaskService } from "./triggerTask.server"; export class TestTaskService extends BaseService { public async call(environment: AuthenticatedEnvironment, data: TestTaskData) { const triggerTaskService = new TriggerTaskService(); + const { triggerSource } = data; - switch (data.triggerSource) { + switch (triggerSource) { case "STANDARD": { const result = await triggerTaskService.call(data.taskIdentifier, environment, { payload: data.payload, @@ -72,8 +73,10 @@ export class TestTaskService extends BaseService { return result?.run; } - default: + default: { + triggerSource satisfies never; throw new Error("Invalid trigger source"); + } } } } diff --git a/apps/webapp/app/v3/taskRunTemplate.ts b/apps/webapp/app/v3/taskRunTemplate.ts new file mode 100644 index 0000000000..3ae3939cf0 --- /dev/null +++ b/apps/webapp/app/v3/taskRunTemplate.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; +import { TestTaskData } from "./testTask"; + +export const RunTemplateData = TestTaskData.and( + z.object({ + label: z.string(), + }) +); + +export type RunTemplateData = z.infer; From 7cf064318cfea909a3d460fc1d079087deaacad4 Mon Sep 17 00:00:00 2001 From: myftija Date: Tue, 8 Jul 2025 10:57:18 +0200 Subject: [PATCH 03/13] Add modal to create run templates in the test page --- .../route.tsx | 268 +++++++++++++++++- 1 file changed, 260 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx index 5246bd08fa..6ab320f103 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx @@ -1,6 +1,6 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { BeakerIcon, RectangleStackIcon } from "@heroicons/react/20/solid"; +import { BeakerIcon, StarIcon, RectangleStackIcon } from "@heroicons/react/20/solid"; import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -60,6 +60,11 @@ import { ClockRotateLeftIcon } from "~/assets/icons/ClockRotateLeftIcon"; import { MachinePresetName } from "@trigger.dev/core/v3"; import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource"; import { Callout } from "~/components/primitives/Callout"; +import { TaskRunTemplateService } from "~/v3/services/taskRunTemplate.server"; +import { RunTemplateData } from "~/v3/taskRunTemplate"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { FormButtons } from "~/components/primitives/FormButtons"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); @@ -104,13 +109,6 @@ export const action: ActionFunction = async ({ request, params }) => { const userId = await requireUserId(request); const { organizationSlug, projectParam, envParam, taskParam } = v3TaskParamsSchema.parse(params); - const formData = await request.formData(); - const submission = parse(formData, { schema: TestTaskData }); - - if (!submission.value) { - return json(submission); - } - const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { return redirectBackWithErrorMessage(request, "Project not found"); @@ -122,6 +120,36 @@ export const action: ActionFunction = async ({ request, params }) => { return redirectBackWithErrorMessage(request, "Environment not found"); } + const formData = await request.formData(); + const formAction = formData.get("formAction"); + + // Handle run template creation + if (formAction === "create-template") { + const runTemplateData = parse(formData, { schema: RunTemplateData }); + if (!runTemplateData.value) { + return json(runTemplateData); + } + + const templateService = new TaskRunTemplateService(); + try { + await templateService.call(environment, runTemplateData.value); + + return json({ + success: true, + message: `Template "${runTemplateData.value.label}" created successfully`, + }); + } catch (e) { + logger.error("Failed to create template", { error: e instanceof Error ? e.message : e }); + return redirectBackWithErrorMessage(request, "Failed to create template"); + } + } + + const submission = parse(formData, { schema: TestTaskData }); + + if (!submission.value) { + return json(submission); + } + if (environment.archivedAt) { return redirectBackWithErrorMessage(request, "Can't run a test on an archived environment"); } @@ -645,6 +673,22 @@ function StandardTaskForm({ + currentPayloadJson.current} + getCurrentMetadata={() => currentMetadataJson.current} + /> + } + cancelButton={ + + + + } + /> + + + + + + ); +} From c03b773c7945396082ee0e71b5c4f8f7556c67fd Mon Sep 17 00:00:00 2001 From: myftija Date: Tue, 8 Jul 2025 12:19:59 +0200 Subject: [PATCH 04/13] Show templates list and apply values when selected --- .../presenters/v3/TestTaskPresenter.server.ts | 51 ++++++++-- .../route.tsx | 92 +++++++++++++++++++ 2 files changed, 136 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts index 79db1b4b78..ed00d6acc7 100644 --- a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts @@ -1,11 +1,19 @@ import { ScheduledTaskPayload, parsePacket, prettyPrintPacket } from "@trigger.dev/core/v3"; -import { type RuntimeEnvironmentType, type TaskRunStatus } from "@trigger.dev/database"; +import { + type TaskRunTemplate, + type RuntimeEnvironmentType, + type TaskRunStatus, +} from "@trigger.dev/database"; import { type PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server"; import { getTimezones } from "~/utils/timezones.server"; import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server"; import { queueTypeFromType } from "./QueueRetrievePresenter.server"; import parse from "parse-duration"; +export type RunTemplate = TaskRunTemplate & { + scheduledTaskPayload?: ScheduledRun["payload"]; +}; + type TestTaskOptions = { userId: string; projectId: string; @@ -40,6 +48,7 @@ export type TestTaskResult = latestVersions: string[]; disableVersionSelection: boolean; allowArbitraryQueues: boolean; + taskRunTemplates: TaskRunTemplate[]; } | { foundTask: true; @@ -51,6 +60,7 @@ export type TestTaskResult = latestVersions: string[]; disableVersionSelection: boolean; allowArbitraryQueues: boolean; + taskRunTemplates: TaskRunTemplate[]; } | { foundTask: false; @@ -163,6 +173,17 @@ export class TestTaskPresenter { take: 20, // last 20 versions should suffice }); + const taskRunTemplates = await this.#prismaClient.taskRunTemplate.findMany({ + where: { + projectId, + taskSlug: task.slug, + }, + orderBy: { + createdAt: "desc", + }, + take: 50, + }); + const latestVersions = backgroundWorkers.map((v) => v.version); const disableVersionSelection = environment.type === "DEVELOPMENT"; @@ -247,6 +268,7 @@ export class TestTaskPresenter { latestVersions, disableVersionSelection, allowArbitraryQueues, + taskRunTemplates, }; case "SCHEDULED": { const possibleTimezones = getTimezones(); @@ -266,7 +288,7 @@ export class TestTaskPresenter { runs: ( await Promise.all( latestRuns.map(async (r) => { - const payload = await getScheduleTaskRunPayload(r); + const payload = await getScheduleTaskRunPayload(r.payload, r.payloadType); if (payload.success) { return { @@ -281,6 +303,21 @@ export class TestTaskPresenter { latestVersions, disableVersionSelection, allowArbitraryQueues, + taskRunTemplates: await Promise.all( + taskRunTemplates.map(async (t) => { + const scheduledTaskPayload = t.payload + ? await getScheduleTaskRunPayload(t.payload, t.payloadType) + : undefined; + + return { + ...t, + scheduledTaskPayload: + scheduledTaskPayload && scheduledTaskPayload.success + ? scheduledTaskPayload.data + : undefined, + }; + }) + ), }; } default: { @@ -290,11 +327,11 @@ export class TestTaskPresenter { } } -async function getScheduleTaskRunPayload(run: RawRun) { - const payload = await parsePacket({ data: run.payload, dataType: run.payloadType }); - if (!payload.timezone) { - payload.timezone = "UTC"; +async function getScheduleTaskRunPayload(payload: string, payloadType: string) { + const packet = await parsePacket({ data: payload, dataType: payloadType }); + if (!packet.timezone) { + packet.timezone = "UTC"; } - const parsed = ScheduledTaskPayload.safeParse(payload); + const parsed = ScheduledTaskPayload.safeParse(packet); return parsed; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx index 6ab320f103..e296b28081 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx @@ -43,6 +43,7 @@ import { type StandardRun, type StandardTaskResult, type ScheduledTaskResult, + type RunTemplate, TestTaskPresenter, } from "~/presenters/v3/TestTaskPresenter.server"; import { logger } from "~/services/logger.server"; @@ -235,6 +236,7 @@ export default function Page() { queues={queues} runs={result.runs} versions={result.latestVersions} + templates={result.taskRunTemplates} disableVersionSelection={result.disableVersionSelection} allowArbitraryQueues={result.allowArbitraryQueues} /> @@ -247,6 +249,7 @@ export default function Page() { queues={queues} runs={result.runs} versions={result.latestVersions} + templates={result.taskRunTemplates} possibleTimezones={result.possibleTimezones} disableVersionSelection={result.disableVersionSelection} allowArbitraryQueues={result.allowArbitraryQueues} @@ -267,6 +270,7 @@ function StandardTaskForm({ queues, runs, versions, + templates, disableVersionSelection, allowArbitraryQueues, }: { @@ -274,6 +278,7 @@ function StandardTaskForm({ queues: Required["queue"][]; runs: StandardRun[]; versions: string[]; + templates: RunTemplate[]; disableVersionSelection: boolean; allowArbitraryQueues: boolean; }) { @@ -374,6 +379,20 @@ function StandardTaskForm({
+ { + setPayload(template.payload ?? ""); + setMetadata(template.metadata ?? ""); + // setTtlValue(template.ttlSeconds ?? ""); + // setConcurrencyKeyValue(template.concurrencyKey ?? ""); + setMaxAttemptsValue(template.maxAttempts ?? undefined); + setMaxDurationValue(template.maxDurationSeconds ?? undefined); + setMachineValue(template.machinePreset ?? undefined); + setTagsValue(template.tags ?? []); + setQueueValue(template.queue); + }} + /> { @@ -709,6 +728,7 @@ function ScheduledTaskForm({ possibleTimezones, queues, versions, + templates, disableVersionSelection, allowArbitraryQueues, }: { @@ -717,6 +737,7 @@ function ScheduledTaskForm({ possibleTimezones: string[]; queues: Required["queue"][]; versions: string[]; + templates: RunTemplate[]; disableVersionSelection: boolean; allowArbitraryQueues: boolean; }) { @@ -811,6 +832,22 @@ function ScheduledTaskForm({
+ { + // setTtlValue(template.ttlSeconds ?? ""); + // setConcurrencyKeyValue(template.concurrencyKey ?? ""); + setMaxAttemptsValue(template.maxAttempts ?? undefined); + setMaxDurationValue(template.maxDurationSeconds ?? undefined); + setMachineValue(template.machinePreset ?? undefined); + setTagsValue(template.tags ?? []); + setQueueValue(template.queue); + setTimestampValue(template.scheduledTaskPayload?.timestamp); + setLastTimestampValue(template.scheduledTaskPayload?.lastTimestamp); + setExternalIdValue(template.scheduledTaskPayload?.externalId); + setTimezoneValue(template.scheduledTaskPayload?.timezone ?? "UTC"); + }} + /> { @@ -1234,6 +1271,61 @@ function RecentRunsPopover({ ); } +function RunTemplatesPopover({ + templates, + onTemplateSelected, +}: { + templates: RunTemplate[]; + onTemplateSelected: (run: RunTemplate) => void; +}) { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + return ( + + + {templates.length === 0 ? ( + + Templates + + } + content="No templates yet" + /> + ) : ( + + )} + + +
+
+ {templates.map((template) => ( + + ))} +
+
+
+
+ ); +} + function CreateTemplateModal({ rawTestTaskFormData, getCurrentPayload, From ecd116558fbc46b58710bbad262142d8ff7bcd0a Mon Sep 17 00:00:00 2001 From: myftija Date: Tue, 8 Jul 2025 12:36:13 +0200 Subject: [PATCH 05/13] Hide template creation time in the dropdown list, only show date --- .../route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx index e296b28081..bc6f08327f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx @@ -1314,7 +1314,7 @@ function RunTemplatesPopover({
{template.label}
- +
From ef695a042bbac14e474ed28292a9d475040a0c03 Mon Sep 17 00:00:00 2001 From: myftija Date: Tue, 8 Jul 2025 14:34:40 +0200 Subject: [PATCH 06/13] Enable deleting run templates --- .../route.tsx | 218 ++++++++++++++---- .../services/deleteTaskRunTemplate.server.ts | 19 ++ apps/webapp/app/v3/taskRunTemplate.ts | 4 + 3 files changed, 195 insertions(+), 46 deletions(-) create mode 100644 apps/webapp/app/v3/services/deleteTaskRunTemplate.server.ts diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx index bc6f08327f..d4ea81e530 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx @@ -1,6 +1,6 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { BeakerIcon, StarIcon, RectangleStackIcon } from "@heroicons/react/20/solid"; +import { BeakerIcon, StarIcon, RectangleStackIcon, TrashIcon } from "@heroicons/react/20/solid"; import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -62,9 +62,10 @@ import { MachinePresetName } from "@trigger.dev/core/v3"; import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource"; import { Callout } from "~/components/primitives/Callout"; import { TaskRunTemplateService } from "~/v3/services/taskRunTemplate.server"; -import { RunTemplateData } from "~/v3/taskRunTemplate"; +import { DeleteTaskRunTemplateService } from "~/v3/services/deleteTaskRunTemplate.server"; +import { DeleteTaskRunTemplateData, RunTemplateData } from "~/v3/taskRunTemplate"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; -import { DialogClose } from "@radix-ui/react-dialog"; +import { DialogClose, DialogDescription } from "@radix-ui/react-dialog"; import { FormButtons } from "~/components/primitives/FormButtons"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { @@ -137,6 +138,7 @@ export const action: ActionFunction = async ({ request, params }) => { return json({ success: true, + formAction, message: `Template "${runTemplateData.value.label}" created successfully`, }); } catch (e) { @@ -145,6 +147,29 @@ export const action: ActionFunction = async ({ request, params }) => { } } + // Handle run template deletion + if (formAction === "delete-template") { + const submission = parse(formData, { schema: DeleteTaskRunTemplateData }); + + if (!submission.value) { + return json(submission); + } + + const deleteService = new DeleteTaskRunTemplateService(); + try { + await deleteService.call(environment, submission.value.templateId); + + return json({ + success: true, + formAction, + message: `Template deleted successfully`, + }); + } catch (e) { + logger.error("Failed to delete template", { error: e instanceof Error ? e.message : e }); + return redirectBackWithErrorMessage(request, "Failed to delete template"); + } + } + const submission = parse(formData, { schema: TestTaskData }); if (!submission.value) { @@ -352,7 +377,14 @@ function StandardTaskForm({ ] = useForm({ id: "test-task", // TODO: type this - lastSubmission: lastSubmission as any, + lastSubmission: + lastSubmission && + typeof lastSubmission === "object" && + "formAction" in lastSubmission && + lastSubmission.formAction !== "create-template" && + lastSubmission.formAction !== "delete-template" + ? (lastSubmission as any) + : undefined, onSubmit(event, { formData }) { event.preventDefault(); @@ -801,7 +833,14 @@ function ScheduledTaskForm({ ] = useForm({ id: "test-task-scheduled", // TODO: type this - lastSubmission: lastSubmission as any, + lastSubmission: + lastSubmission && + typeof lastSubmission === "object" && + "formAction" in lastSubmission && + lastSubmission.formAction !== "create-template" && + lastSubmission.formAction !== "delete-template" + ? (lastSubmission as any) + : undefined, onValidate({ formData }) { return parse(formData, { schema: TestTaskData }); }, @@ -1279,50 +1318,131 @@ function RunTemplatesPopover({ onTemplateSelected: (run: RunTemplate) => void; }) { const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [templateIdToDelete, setTemplateIdToDelete] = useState(); + + const lastSubmission = useActionData(); + + useEffect(() => { + if ( + lastSubmission && + typeof lastSubmission === "object" && + "formAction" in lastSubmission && + "success" in lastSubmission && + lastSubmission.formAction === "delete-template" && + lastSubmission.success === true + ) { + setIsDeleteDialogOpen(false); + } + }, [lastSubmission]); + + const [deleteForm, { templateId }] = useForm({ + id: "delete-template", + onValidate({ formData }) { + return parse(formData, { schema: DeleteTaskRunTemplateData }); + }, + }); return ( - - - {templates.length === 0 ? ( - - Templates - - } - content="No templates yet" - /> - ) : ( - - )} - - -
-
- {templates.map((template) => ( - + } + content="No templates yet" + /> + ) : ( + + )} + + +
+
+ {templates.map((template) => ( +
+ +
- - ))} + ))} +
-
- - + + + + + + Delete template + + Are you sure you want to delete the template? This can't be reversed. + +
+ +
+ + + +
+
+
+
+ ); } @@ -1392,7 +1512,13 @@ function CreateTemplateModal({ }, ] = useForm({ id: "save-template", - lastSubmission: lastSubmission as any, + lastSubmission: + lastSubmission && + typeof lastSubmission === "object" && + "formAction" in lastSubmission && + lastSubmission.formAction === "create-template" + ? (lastSubmission as any) + : undefined, onSubmit(event, { formData }) { event.preventDefault(); diff --git a/apps/webapp/app/v3/services/deleteTaskRunTemplate.server.ts b/apps/webapp/app/v3/services/deleteTaskRunTemplate.server.ts new file mode 100644 index 0000000000..72d2ef7dba --- /dev/null +++ b/apps/webapp/app/v3/services/deleteTaskRunTemplate.server.ts @@ -0,0 +1,19 @@ +import { BaseService } from "./baseService.server"; +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; + +export class DeleteTaskRunTemplateService extends BaseService { + public async call(environment: AuthenticatedEnvironment, templateId: string) { + try { + await this._prisma.taskRunTemplate.delete({ + where: { + id: templateId, + projectId: environment.projectId, + }, + }); + } catch (e) { + throw new Error( + `Error deleting template: ${e instanceof Error ? e.message : JSON.stringify(e)}` + ); + } + } +} diff --git a/apps/webapp/app/v3/taskRunTemplate.ts b/apps/webapp/app/v3/taskRunTemplate.ts index 3ae3939cf0..3e7a1f1921 100644 --- a/apps/webapp/app/v3/taskRunTemplate.ts +++ b/apps/webapp/app/v3/taskRunTemplate.ts @@ -8,3 +8,7 @@ export const RunTemplateData = TestTaskData.and( ); export type RunTemplateData = z.infer; + +export const DeleteTaskRunTemplateData = z.object({ + templateId: z.string(), +}); From 3646f8c3e6e25e90e5c72520d11a9433264aba04 Mon Sep 17 00:00:00 2001 From: myftija Date: Tue, 8 Jul 2025 15:38:23 +0200 Subject: [PATCH 07/13] Show success toast on template creation --- .../route.tsx | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx index d4ea81e530..3acb6a56f1 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx @@ -30,7 +30,7 @@ import { TextLink } from "~/components/primitives/TextLink"; import { TimezoneList } from "~/components/scheduled/timezones"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useSearchParams } from "~/hooks/useSearchParam"; -import { useParams, Form, useActionData, useFetcher } from "@remix-run/react"; +import { useParams, Form, useActionData, useFetcher, useSubmit } from "@remix-run/react"; import { redirectBackWithErrorMessage, redirectWithErrorMessage, @@ -67,6 +67,8 @@ import { DeleteTaskRunTemplateData, RunTemplateData } from "~/v3/taskRunTemplate import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { DialogClose, DialogDescription } from "@radix-ui/react-dialog"; import { FormButtons } from "~/components/primitives/FormButtons"; +import { toast } from "sonner"; +import { ToastUI } from "~/components/primitives/Toast"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); @@ -129,7 +131,7 @@ export const action: ActionFunction = async ({ request, params }) => { if (formAction === "create-template") { const runTemplateData = parse(formData, { schema: RunTemplateData }); if (!runTemplateData.value) { - return json(runTemplateData); + return json(runTemplateData.error); } const templateService = new TaskRunTemplateService(); @@ -1471,22 +1473,31 @@ function CreateTemplateModal({ getCurrentPayload: () => string; getCurrentMetadata: () => string; }) { - const lastSubmission = useActionData(); - - const fetcher = useFetcher(); + const submit = useSubmit(); const [isModalOpen, setIsModalOpen] = useState(false); + const lastSubmission = useActionData(); + useEffect(() => { if ( - fetcher.state === "idle" && - fetcher.data && - typeof fetcher.data === "object" && - "success" in fetcher.data && - fetcher.data.success + lastSubmission && + typeof lastSubmission === "object" && + "formAction" in lastSubmission && + "success" in lastSubmission && + lastSubmission.formAction === "create-template" && + lastSubmission.success === true ) { setIsModalOpen(false); + toast.custom( + (t) => ( + + ), + { + duration: 2000, + } + ); } - }, [fetcher.state, fetcher.data]); + }, [lastSubmission]); const [ form, @@ -1512,20 +1523,13 @@ function CreateTemplateModal({ }, ] = useForm({ id: "save-template", - lastSubmission: - lastSubmission && - typeof lastSubmission === "object" && - "formAction" in lastSubmission && - lastSubmission.formAction === "create-template" - ? (lastSubmission as any) - : undefined, onSubmit(event, { formData }) { event.preventDefault(); formData.set(payload.name, getCurrentPayload()); formData.set(metadata.name, getCurrentMetadata()); - fetcher.submit(formData, { method: "POST" }); + submit(formData, { method: "POST" }); }, onValidate({ formData }) { return parse(formData, { schema: RunTemplateData }); From 4a81e82d0263b1fa5e3b2b586a114c994d638e0d Mon Sep 17 00:00:00 2001 From: myftija Date: Tue, 8 Jul 2025 15:43:06 +0200 Subject: [PATCH 08/13] Validate template label length --- .../route.tsx | 8 ++++++-- apps/webapp/app/v3/taskRunTemplate.ts | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx index 3acb6a56f1..97cdd0a3e6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx @@ -1614,8 +1614,12 @@ function CreateTemplateModal({
- - + + {label.error} {form.error} diff --git a/apps/webapp/app/v3/taskRunTemplate.ts b/apps/webapp/app/v3/taskRunTemplate.ts index 3e7a1f1921..46bbc797eb 100644 --- a/apps/webapp/app/v3/taskRunTemplate.ts +++ b/apps/webapp/app/v3/taskRunTemplate.ts @@ -3,7 +3,7 @@ import { TestTaskData } from "./testTask"; export const RunTemplateData = TestTaskData.and( z.object({ - label: z.string(), + label: z.string().max(42, "Labels can be at most 42 characters long"), }) ); From a738281ac6c0f19f08a8e9c522463d185ca598fc Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 9 Jul 2025 11:55:07 +0200 Subject: [PATCH 09/13] Improve the template creation success indicator --- .../route.tsx | 101 ++++++++++++------ 1 file changed, 71 insertions(+), 30 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx index 97cdd0a3e6..51ecc6120e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx @@ -1,6 +1,13 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { BeakerIcon, StarIcon, RectangleStackIcon, TrashIcon } from "@heroicons/react/20/solid"; +import { + BeakerIcon, + StarIcon, + RectangleStackIcon, + TrashIcon, + CheckCircleIcon, +} from "@heroicons/react/20/solid"; +import { AnimatePresence, motion } from "framer-motion"; import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -355,6 +362,8 @@ function StandardTaskForm({ paused: q.paused, })); + const [showTemplateCreatedSuccessMessage, setShowTemplateCreatedSuccessMessage] = useState(false); + const fetcher = useFetcher(); const [ form, @@ -426,6 +435,7 @@ function StandardTaskForm({ setTagsValue(template.tags ?? []); setQueueValue(template.queue); }} + showTemplateCreatedSuccessMessage={showTemplateCreatedSuccessMessage} /> currentPayloadJson.current} getCurrentMetadata={() => currentMetadataJson.current} + setShowCreatedSuccessMessage={setShowTemplateCreatedSuccessMessage} /> - } - content="No templates yet" - /> - ) : ( - - )} +
@@ -1417,6 +1423,43 @@ function RunTemplatesPopover({ + + {showTemplateCreatedSuccessMessage && ( + + Template created + successfully + + )} + + Delete template @@ -1444,7 +1487,7 @@ function RunTemplatesPopover({
- +
); } @@ -1452,6 +1495,7 @@ function CreateTemplateModal({ rawTestTaskFormData, getCurrentPayload, getCurrentMetadata, + setShowCreatedSuccessMessage, }: { rawTestTaskFormData: { environmentId: string; @@ -1472,6 +1516,7 @@ function CreateTemplateModal({ }; getCurrentPayload: () => string; getCurrentMetadata: () => string; + setShowCreatedSuccessMessage: (value: boolean) => void; }) { const submit = useSubmit(); const [isModalOpen, setIsModalOpen] = useState(false); @@ -1488,14 +1533,10 @@ function CreateTemplateModal({ lastSubmission.success === true ) { setIsModalOpen(false); - toast.custom( - (t) => ( - - ), - { - duration: 2000, - } - ); + setShowCreatedSuccessMessage(true); + setTimeout(() => { + setShowCreatedSuccessMessage(false); + }, 2000); } }, [lastSubmission]); From dc182d5ac1fbf2afdc09ebabecd30b7abf4c1c47 Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 9 Jul 2025 16:59:36 +0200 Subject: [PATCH 10/13] Use formAction consistently to differentiate submissions --- .../route.tsx | 124 ++++++++++-------- 1 file changed, 72 insertions(+), 52 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx index 51ecc6120e..8ad9aac0e9 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx @@ -74,8 +74,6 @@ import { DeleteTaskRunTemplateData, RunTemplateData } from "~/v3/taskRunTemplate import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { DialogClose, DialogDescription } from "@radix-ui/react-dialog"; import { FormButtons } from "~/components/primitives/FormButtons"; -import { toast } from "sonner"; -import { ToastUI } from "~/components/primitives/Toast"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); @@ -118,7 +116,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { export const action: ActionFunction = async ({ request, params }) => { const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam, taskParam } = v3TaskParamsSchema.parse(params); + const { organizationSlug, projectParam, envParam } = v3TaskParamsSchema.parse(params); const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { @@ -136,19 +134,23 @@ export const action: ActionFunction = async ({ request, params }) => { // Handle run template creation if (formAction === "create-template") { - const runTemplateData = parse(formData, { schema: RunTemplateData }); - if (!runTemplateData.value) { - return json(runTemplateData.error); + const submission = parse(formData, { schema: RunTemplateData }); + if (!submission.value) { + return json({ + ...submission, + formAction, + }); } const templateService = new TaskRunTemplateService(); try { - await templateService.call(environment, runTemplateData.value); + const template = await templateService.call(environment, submission.value); return json({ + ...submission, success: true, + templateLabel: template.label, formAction, - message: `Template "${runTemplateData.value.label}" created successfully`, }); } catch (e) { logger.error("Failed to create template", { error: e instanceof Error ? e.message : e }); @@ -161,7 +163,10 @@ export const action: ActionFunction = async ({ request, params }) => { const submission = parse(formData, { schema: DeleteTaskRunTemplateData }); if (!submission.value) { - return json(submission); + return json({ + ...submission, + formAction, + }); } const deleteService = new DeleteTaskRunTemplateService(); @@ -169,9 +174,9 @@ export const action: ActionFunction = async ({ request, params }) => { await deleteService.call(environment, submission.value.templateId); return json({ + ...submission, success: true, formAction, - message: `Template deleted successfully`, }); } catch (e) { logger.error("Failed to delete template", { error: e instanceof Error ? e.message : e }); @@ -182,7 +187,10 @@ export const action: ActionFunction = async ({ request, params }) => { const submission = parse(formData, { schema: TestTaskData }); if (!submission.value) { - return json(submission); + return json({ + ...submission, + formAction, + }); } if (environment.archivedAt) { @@ -320,7 +328,16 @@ function StandardTaskForm({ const { value, replace } = useSearchParams(); const tab = value("tab"); - const lastSubmission = useActionData(); + const submit = useSubmit(); + const actionData = useActionData(); + const lastSubmission = + actionData && + typeof actionData === "object" && + "formAction" in actionData && + actionData.formAction === "run-standard" + ? actionData + : undefined; + const lastRun = runs[0]; const [defaultPayloadJson, setDefaultPayloadJson] = useState( @@ -364,7 +381,6 @@ function StandardTaskForm({ const [showTemplateCreatedSuccessMessage, setShowTemplateCreatedSuccessMessage] = useState(false); - const fetcher = useFetcher(); const [ form, { @@ -388,21 +404,14 @@ function StandardTaskForm({ ] = useForm({ id: "test-task", // TODO: type this - lastSubmission: - lastSubmission && - typeof lastSubmission === "object" && - "formAction" in lastSubmission && - lastSubmission.formAction !== "create-template" && - lastSubmission.formAction !== "delete-template" - ? (lastSubmission as any) - : undefined, + lastSubmission: lastSubmission as any, onSubmit(event, { formData }) { event.preventDefault(); formData.set(payload.name, currentPayloadJson.current); formData.set(metadata.name, currentMetadataJson.current); - fetcher.submit(formData, { method: "POST" }); + submit(formData, { method: "POST" }); }, onValidate({ formData }) { return parse(formData, { schema: TestTaskData }); @@ -758,6 +767,8 @@ function StandardTaskForm({ variant="primary/medium" LeadingIcon={BeakerIcon} shortcut={{ key: "enter", modifiers: ["mod"], enabledOnInputElements: true }} + name="formAction" + value="run-standard" > Run test @@ -787,7 +798,6 @@ function ScheduledTaskForm({ allowArbitraryQueues: boolean; }) { const environment = useEnvironment(); - const lastSubmission = useActionData(); const lastRun = runs[0]; @@ -824,6 +834,15 @@ function ScheduledTaskForm({ paused: q.paused, })); + const actionData = useActionData(); + const lastSubmission = + actionData && + typeof actionData === "object" && + "formAction" in actionData && + actionData.formAction === "run-scheduled" + ? actionData + : undefined; + const [ form, { @@ -848,14 +867,7 @@ function ScheduledTaskForm({ ] = useForm({ id: "test-task-scheduled", // TODO: type this - lastSubmission: - lastSubmission && - typeof lastSubmission === "object" && - "formAction" in lastSubmission && - lastSubmission.formAction !== "create-template" && - lastSubmission.formAction !== "delete-template" - ? (lastSubmission as any) - : undefined, + lastSubmission: lastSubmission as any, onValidate({ formData }) { return parse(formData, { schema: TestTaskData }); }, @@ -1264,6 +1276,8 @@ function ScheduledTaskForm({ variant="primary/medium" LeadingIcon={BeakerIcon} shortcut={{ key: "enter", modifiers: ["mod"], enabledOnInputElements: true }} + name="formAction" + value="run-scheduled" > Run test @@ -1340,17 +1354,17 @@ function RunTemplatesPopover({ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [templateIdToDelete, setTemplateIdToDelete] = useState(); - const lastSubmission = useActionData(); + const actionData = useActionData(); + const lastSubmission = + actionData && + typeof actionData === "object" && + "formAction" in actionData && + actionData.formAction === "delete-template" + ? actionData + : undefined; useEffect(() => { - if ( - lastSubmission && - typeof lastSubmission === "object" && - "formAction" in lastSubmission && - "success" in lastSubmission && - lastSubmission.formAction === "delete-template" && - lastSubmission.success === true - ) { + if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) { setIsDeleteDialogOpen(false); } }, [lastSubmission]); @@ -1454,7 +1468,7 @@ function RunTemplatesPopover({ }} 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-charcoal-700 bg-background-bright px-2 py-1 text-xs shadow-md outline-none before:absolute before:-top-2 before:left-1/2 before:-translate-x-1/2 before:border-4 before:border-transparent before:border-b-charcoal-700 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-['']" > - Template created + Template saved successfully )} @@ -1475,12 +1489,17 @@ function RunTemplatesPopover({ Cancel
- -
@@ -1521,17 +1540,17 @@ function CreateTemplateModal({ const submit = useSubmit(); const [isModalOpen, setIsModalOpen] = useState(false); - const lastSubmission = useActionData(); + const actionData = useActionData(); + const lastSubmission = + actionData && + typeof actionData === "object" && + "formAction" in actionData && + actionData.formAction === "create-template" + ? actionData + : undefined; useEffect(() => { - if ( - lastSubmission && - typeof lastSubmission === "object" && - "formAction" in lastSubmission && - "success" in lastSubmission && - lastSubmission.formAction === "create-template" && - lastSubmission.success === true - ) { + if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) { setIsModalOpen(false); setShowCreatedSuccessMessage(true); setTimeout(() => { @@ -1564,6 +1583,7 @@ function CreateTemplateModal({ }, ] = useForm({ id: "save-template", + lastSubmission: lastSubmission as any, onSubmit(event, { formData }) { event.preventDefault(); From 56aa39e3c11862510a14a4b7e09fc62991412501 Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 9 Jul 2025 17:11:29 +0200 Subject: [PATCH 11/13] Type formAction for better editor support --- .../route.tsx | 221 +++++++++--------- 1 file changed, 114 insertions(+), 107 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx index 8ad9aac0e9..acdfcececb 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx @@ -75,6 +75,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components import { DialogClose, DialogDescription } from "@radix-ui/react-dialog"; import { FormButtons } from "~/components/primitives/FormButtons"; +type FormAction = "create-template" | "delete-template" | "run-scheduled" | "run-standard"; + export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); const { projectParam, organizationSlug, envParam, taskParam } = v3TaskParamsSchema.parse(params); @@ -130,109 +132,114 @@ export const action: ActionFunction = async ({ request, params }) => { } const formData = await request.formData(); - const formAction = formData.get("formAction"); - - // Handle run template creation - if (formAction === "create-template") { - const submission = parse(formData, { schema: RunTemplateData }); - if (!submission.value) { - return json({ - ...submission, - formAction, - }); - } - - const templateService = new TaskRunTemplateService(); - try { - const template = await templateService.call(environment, submission.value); - - return json({ - ...submission, - 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"); - } - } - - // Handle run template deletion - if (formAction === "delete-template") { - const submission = parse(formData, { schema: DeleteTaskRunTemplateData }); - - if (!submission.value) { - return json({ - ...submission, - formAction, - }); + const formAction = formData.get("formAction") as FormAction; + + switch (formAction) { + case "create-template": { + const submission = parse(formData, { schema: RunTemplateData }); + if (!submission.value) { + return json({ + ...submission, + formAction, + }); + } + + const templateService = new TaskRunTemplateService(); + try { + const template = await templateService.call(environment, submission.value); + + return json({ + ...submission, + 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"); + } } - - const deleteService = new DeleteTaskRunTemplateService(); - try { - await deleteService.call(environment, submission.value.templateId); - - return json({ - ...submission, - 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 "delete-template": { + const submission = parse(formData, { schema: DeleteTaskRunTemplateData }); + + if (!submission.value) { + return json({ + ...submission, + formAction, + }); + } + + const deleteService = new DeleteTaskRunTemplateService(); + try { + await deleteService.call(environment, submission.value.templateId); + + return json({ + ...submission, + 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"); + } } - } - - const submission = parse(formData, { schema: TestTaskData }); - - if (!submission.value) { - return json({ - ...submission, - 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" - ); + case "run-scheduled": + case "run-standard": { + const submission = parse(formData, { schema: TestTaskData }); + + if (!submission.value) { + return json({ + ...submission, + 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" + ); + } } - - 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" - ); + default: { + formAction satisfies never; + return redirectBackWithErrorMessage(request, "Failed to process request"); } - - 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" - ); } }; @@ -334,7 +341,7 @@ function StandardTaskForm({ actionData && typeof actionData === "object" && "formAction" in actionData && - actionData.formAction === "run-standard" + actionData.formAction === ("run-standard" satisfies FormAction) ? actionData : undefined; @@ -768,7 +775,7 @@ function StandardTaskForm({ LeadingIcon={BeakerIcon} shortcut={{ key: "enter", modifiers: ["mod"], enabledOnInputElements: true }} name="formAction" - value="run-standard" + value={"run-standard" satisfies FormAction} > Run test @@ -839,7 +846,7 @@ function ScheduledTaskForm({ actionData && typeof actionData === "object" && "formAction" in actionData && - actionData.formAction === "run-scheduled" + actionData.formAction === ("run-scheduled" satisfies FormAction) ? actionData : undefined; @@ -1277,7 +1284,7 @@ function ScheduledTaskForm({ LeadingIcon={BeakerIcon} shortcut={{ key: "enter", modifiers: ["mod"], enabledOnInputElements: true }} name="formAction" - value="run-scheduled" + value={"run-scheduled" satisfies FormAction} > Run test @@ -1359,7 +1366,7 @@ function RunTemplatesPopover({ actionData && typeof actionData === "object" && "formAction" in actionData && - actionData.formAction === "delete-template" + actionData.formAction === ("delete-template" satisfies FormAction) ? actionData : undefined; @@ -1498,7 +1505,7 @@ function RunTemplatesPopover({ variant="danger/medium" LeadingIcon={TrashIcon} name="formAction" - value="delete-template" + value={"delete-template" satisfies FormAction} > Delete @@ -1545,7 +1552,7 @@ function CreateTemplateModal({ actionData && typeof actionData === "object" && "formAction" in actionData && - actionData.formAction === "create-template" + actionData.formAction === ("create-template" satisfies FormAction) ? actionData : undefined; @@ -1690,7 +1697,7 @@ function CreateTemplateModal({ type="submit" variant="primary/medium" name="formAction" - value="create-template" + value={"create-template" satisfies FormAction} > Create template From e047c6f92a559847f572b7e57f67827b20bbe7b9 Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 9 Jul 2025 17:17:36 +0200 Subject: [PATCH 12/13] Prettify run template payload and metadata --- apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts index ed00d6acc7..a51c8894a3 100644 --- a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts @@ -268,7 +268,13 @@ export class TestTaskPresenter { latestVersions, disableVersionSelection, allowArbitraryQueues, - taskRunTemplates, + taskRunTemplates: await Promise.all( + taskRunTemplates.map(async (t) => ({ + ...t, + payload: await prettyPrintPacket(t.payload, t.payloadType), + metadata: t.metadata ? await prettyPrintPacket(t.metadata, t.metadataType) : null, + })) + ), }; case "SCHEDULED": { const possibleTimezones = getTimezones(); From e2d36a5e7e94cff17ce742d763d6d52ed2d3e018 Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 9 Jul 2025 18:00:32 +0200 Subject: [PATCH 13/13] Add triggerSource, concurrencyKey and ttl to run templates --- .../presenters/v3/TestTaskPresenter.server.ts | 1 + .../route.tsx | 37 ++++++++++--------- .../app/v3/services/taskRunTemplate.server.ts | 12 ++++-- .../migration.sql | 8 ++-- .../database/prisma/schema.prisma | 8 ++-- 5 files changed, 38 insertions(+), 28 deletions(-) rename internal-packages/database/prisma/migrations/{20250707142736_add_task_run_template_model => 20250709162608_add_task_run_template_model}/migration.sql (68%) diff --git a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts index a51c8894a3..23fe6bca94 100644 --- a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts @@ -177,6 +177,7 @@ export class TestTaskPresenter { where: { projectId, taskSlug: task.slug, + triggerSource: task.triggerSource, }, orderBy: { createdAt: "desc", diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx index acdfcececb..d869f08dc5 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx @@ -67,7 +67,6 @@ import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus"; import { ClockRotateLeftIcon } from "~/assets/icons/ClockRotateLeftIcon"; import { MachinePresetName } from "@trigger.dev/core/v3"; import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource"; -import { Callout } from "~/components/primitives/Callout"; import { TaskRunTemplateService } from "~/v3/services/taskRunTemplate.server"; import { DeleteTaskRunTemplateService } from "~/v3/services/deleteTaskRunTemplate.server"; import { DeleteTaskRunTemplateData, RunTemplateData } from "~/v3/taskRunTemplate"; @@ -277,6 +276,7 @@ export default function Page() { }, [queueFetcher.data?.queues, defaultTaskQueue]); const { triggerSource } = result; + switch (triggerSource) { case "STANDARD": { return ( @@ -345,7 +345,7 @@ function StandardTaskForm({ ? actionData : undefined; - const lastRun = runs[0]; + const lastRun = runs.at(0); const [defaultPayloadJson, setDefaultPayloadJson] = useState( lastRun?.payload ?? startingJson @@ -443,13 +443,13 @@ function StandardTaskForm({ onTemplateSelected={(template) => { setPayload(template.payload ?? ""); setMetadata(template.metadata ?? ""); - // setTtlValue(template.ttlSeconds ?? ""); - // setConcurrencyKeyValue(template.concurrencyKey ?? ""); + setTtlValue(template.ttlSeconds ?? 0); + setConcurrencyKeyValue(template.concurrencyKey ?? ""); setMaxAttemptsValue(template.maxAttempts ?? undefined); - setMaxDurationValue(template.maxDurationSeconds ?? undefined); - setMachineValue(template.machinePreset ?? undefined); + setMaxDurationValue(template.maxDurationSeconds ?? 0); + setMachineValue(template.machinePreset ?? ""); setTagsValue(template.tags ?? []); - setQueueValue(template.queue); + setQueueValue(template.queue ?? undefined); }} showTemplateCreatedSuccessMessage={showTemplateCreatedSuccessMessage} /> @@ -806,18 +806,18 @@ function ScheduledTaskForm({ }) { const environment = useEnvironment(); - const lastRun = runs[0]; + const lastRun = runs.at(0); const [timestampValue, setTimestampValue] = useState( - lastRun.payload.timestamp ?? new Date() + lastRun?.payload?.timestamp ?? new Date() ); const [lastTimestampValue, setLastTimestampValue] = useState( - lastRun.payload.lastTimestamp + lastRun?.payload?.lastTimestamp ); const [externalIdValue, setExternalIdValue] = useState( - lastRun.payload.externalId + lastRun?.payload?.externalId ); - const [timezoneValue, setTimezoneValue] = useState(lastRun.payload.timezone ?? "UTC"); + const [timezoneValue, setTimezoneValue] = useState(lastRun?.payload?.timezone ?? "UTC"); const [ttlValue, setTtlValue] = useState(lastRun?.ttlSeconds); const [concurrencyKeyValue, setConcurrencyKeyValue] = useState( lastRun?.concurrencyKey @@ -908,13 +908,14 @@ function ScheduledTaskForm({ { - // setTtlValue(template.ttlSeconds ?? ""); - // setConcurrencyKeyValue(template.concurrencyKey ?? ""); + setTtlValue(template.ttlSeconds ?? 0); + setConcurrencyKeyValue(template.concurrencyKey ?? ""); setMaxAttemptsValue(template.maxAttempts ?? undefined); - setMaxDurationValue(template.maxDurationSeconds ?? undefined); - setMachineValue(template.machinePreset ?? undefined); + setMaxDurationValue(template.maxDurationSeconds ?? 0); + setMachineValue(template.machinePreset ?? ""); setTagsValue(template.tags ?? []); - setQueueValue(template.queue); + setQueueValue(template.queue ?? undefined); + setTimestampValue(template.scheduledTaskPayload?.timestamp); setLastTimestampValue(template.scheduledTaskPayload?.lastTimestamp); setExternalIdValue(template.scheduledTaskPayload?.externalId); @@ -1413,7 +1414,7 @@ function RunTemplatesPopover({ className="flex-1 text-left outline-none focus-custom" >
- + {template.label}
diff --git a/apps/webapp/app/v3/services/taskRunTemplate.server.ts b/apps/webapp/app/v3/services/taskRunTemplate.server.ts index 92f5bd2809..827bffd021 100644 --- a/apps/webapp/app/v3/services/taskRunTemplate.server.ts +++ b/apps/webapp/app/v3/services/taskRunTemplate.server.ts @@ -30,13 +30,15 @@ export class TaskRunTemplateService extends BaseService { const taskRunTemplate = await this._prisma.taskRunTemplate.create({ data: { taskSlug: data.taskIdentifier, + triggerSource: "STANDARD", label: data.label, payload: packet.data, payloadType: packet.dataType, metadata: metadataPacket?.data, metadataType: metadataPacket?.dataType, - queue: data.queue ?? "default", - delaySeconds: data.delaySeconds, + queue: data.queue, + ttlSeconds: data.ttlSeconds, + concurrencyKey: data.concurrencyKey, maxAttempts: data.maxAttempts, maxDurationSeconds: data.maxDurationSeconds, tags: data.tags ?? [], @@ -63,11 +65,13 @@ export class TaskRunTemplateService extends BaseService { const taskRunTemplate = await this._prisma.taskRunTemplate.create({ data: { taskSlug: data.taskIdentifier, + triggerSource: "SCHEDULED", label: data.label, payload: payloadPacket.data, payloadType: payloadPacket.dataType, - queue: data.queue ?? "default", - delaySeconds: data.delaySeconds, + queue: data.queue, + ttlSeconds: data.ttlSeconds, + concurrencyKey: data.concurrencyKey, maxAttempts: data.maxAttempts, maxDurationSeconds: data.maxDurationSeconds, tags: data.tags ?? [], diff --git a/internal-packages/database/prisma/migrations/20250707142736_add_task_run_template_model/migration.sql b/internal-packages/database/prisma/migrations/20250709162608_add_task_run_template_model/migration.sql similarity index 68% rename from internal-packages/database/prisma/migrations/20250707142736_add_task_run_template_model/migration.sql rename to internal-packages/database/prisma/migrations/20250709162608_add_task_run_template_model/migration.sql index 33cdc4848f..f84703d936 100644 --- a/internal-packages/database/prisma/migrations/20250707142736_add_task_run_template_model/migration.sql +++ b/internal-packages/database/prisma/migrations/20250709162608_add_task_run_template_model/migration.sql @@ -1,13 +1,15 @@ CREATE TABLE "TaskRunTemplate" ( "id" TEXT NOT NULL, "taskSlug" TEXT NOT NULL, + "triggerSource" "TaskTriggerSource" NOT NULL, "label" TEXT NOT NULL, "payload" TEXT, "payloadType" TEXT NOT NULL DEFAULT 'application/json', "metadata" TEXT, "metadataType" TEXT NOT NULL DEFAULT 'application/json', - "queue" TEXT NOT NULL, - "delaySeconds" INTEGER, + "queue" TEXT, + "concurrencyKey" TEXT, + "ttlSeconds" INTEGER, "maxAttempts" INTEGER, "maxDurationSeconds" INTEGER, "tags" TEXT[], @@ -20,4 +22,4 @@ CREATE TABLE "TaskRunTemplate" ( CONSTRAINT "TaskRunTemplate_pkey" PRIMARY KEY ("id") ); -CREATE INDEX "TaskRunTemplate_projectId_taskSlug_createdAt_idx" ON "TaskRunTemplate"("projectId", "taskSlug", "createdAt" DESC); \ No newline at end of file +CREATE INDEX "TaskRunTemplate_projectId_taskSlug_triggerSource_createdAt_idx" ON "TaskRunTemplate"("projectId", "taskSlug", "triggerSource", "createdAt" DESC); diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 8298120a20..9d7f648c5d 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -720,6 +720,7 @@ model TaskRunTemplate { id String @id @default(cuid()) taskSlug String + triggerSource TaskTriggerSource label String @@ -727,8 +728,9 @@ model TaskRunTemplate { payloadType String @default("application/json") metadata String? metadataType String @default("application/json") - queue String - delaySeconds Int? + queue String? + concurrencyKey String? + ttlSeconds Int? maxAttempts Int? maxDurationSeconds Int? tags String[] @@ -740,7 +742,7 @@ model TaskRunTemplate { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@index([projectId, taskSlug, createdAt(sort: Desc)]) + @@index([projectId, taskSlug, triggerSource, createdAt(sort: Desc)]) } enum TaskRunStatus {