diff --git a/apps/webapp/app/components/primitives/DurationPicker.tsx b/apps/webapp/app/components/primitives/DurationPicker.tsx new file mode 100644 index 0000000000..bf405dee36 --- /dev/null +++ b/apps/webapp/app/components/primitives/DurationPicker.tsx @@ -0,0 +1,177 @@ +import { Input } from "~/components/primitives/Input"; +import { cn } from "~/utils/cn"; +import React, { useRef, useState, useEffect } from "react"; +import { Button } from "./Buttons"; + +export interface DurationPickerProps { + id?: string; // used for the hidden input for form submission + name?: string; // used for the hidden input for form submission + defaultValueSeconds?: number; + onChange?: (totalSeconds: number) => void; + variant?: "small" | "medium"; + showClearButton?: boolean; +} + +export function DurationPicker({ + name, + defaultValueSeconds: defaultValue = 0, + onChange, + variant = "small", + showClearButton = true, +}: DurationPickerProps) { + const defaultHours = Math.floor(defaultValue / 3600); + const defaultMinutes = Math.floor((defaultValue % 3600) / 60); + const defaultSeconds = defaultValue % 60; + + const [hours, setHours] = useState(defaultHours); + const [minutes, setMinutes] = useState(defaultMinutes); + const [seconds, setSeconds] = useState(defaultSeconds); + + const minuteRef = useRef(null); + const hourRef = useRef(null); + const secondRef = useRef(null); + + const totalSeconds = hours * 3600 + minutes * 60 + seconds; + + const isEmpty = hours === 0 && minutes === 0 && seconds === 0; + + useEffect(() => { + onChange?.(totalSeconds); + }, [totalSeconds, onChange]); + + const handleHoursChange = (e: React.ChangeEvent) => { + const value = parseInt(e.target.value) || 0; + setHours(Math.max(0, value)); + }; + + const handleMinutesChange = (e: React.ChangeEvent) => { + const value = parseInt(e.target.value) || 0; + if (value >= 60) { + setHours((prev) => prev + Math.floor(value / 60)); + setMinutes(value % 60); + return; + } + + setMinutes(Math.max(0, Math.min(59, value))); + }; + + const handleSecondsChange = (e: React.ChangeEvent) => { + const value = parseInt(e.target.value) || 0; + if (value >= 60) { + setMinutes((prev) => { + const newMinutes = prev + Math.floor(value / 60); + if (newMinutes >= 60) { + setHours((prevHours) => prevHours + Math.floor(newMinutes / 60)); + return newMinutes % 60; + } + return newMinutes; + }); + setSeconds(value % 60); + return; + } + + setSeconds(Math.max(0, Math.min(59, value))); + }; + + const handleKeyDown = ( + e: React.KeyboardEvent, + nextRef?: React.RefObject, + prevRef?: React.RefObject + ) => { + if (e.key === "Tab") { + return; + } + + if (e.key === "ArrowRight" && nextRef) { + e.preventDefault(); + nextRef.current?.focus(); + nextRef.current?.select(); + return; + } + + if (e.key === "ArrowLeft" && prevRef) { + e.preventDefault(); + prevRef.current?.focus(); + prevRef.current?.select(); + return; + } + }; + + const clearDuration = () => { + setHours(0); + setMinutes(0); + setSeconds(0); + hourRef.current?.focus(); + }; + + return ( +
+ + +
+
+ handleKeyDown(e, minuteRef)} + onFocus={(e) => e.target.select()} + type="number" + min={0} + inputMode="numeric" + /> + h +
+
+ handleKeyDown(e, secondRef, hourRef)} + onFocus={(e) => e.target.select()} + type="number" + min={0} + max={59} + inputMode="numeric" + /> + m +
+
+ handleKeyDown(e, undefined, minuteRef)} + onFocus={(e) => e.target.select()} + type="number" + min={0} + max={59} + inputMode="numeric" + /> + s +
+
+ + {showClearButton && ( + + )} +
+ ); +} diff --git a/apps/webapp/app/components/runs/v3/RunTag.tsx b/apps/webapp/app/components/runs/v3/RunTag.tsx index c7aab7cb09..14baeca1a3 100644 --- a/apps/webapp/app/components/runs/v3/RunTag.tsx +++ b/apps/webapp/app/components/runs/v3/RunTag.tsx @@ -3,11 +3,21 @@ import tagLeftPath from "./tag-left.svg"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { Link } from "@remix-run/react"; import { cn } from "~/utils/cn"; -import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react"; +import { ClipboardCheckIcon, ClipboardIcon, XIcon } from "lucide-react"; type Tag = string | { key: string; value: string }; -export function RunTag({ tag, to, tooltip }: { tag: string; to?: string; tooltip?: string }) { +export function RunTag({ + tag, + to, + tooltip, + action = { type: "copy" }, +}: { + tag: string; + action?: { type: "copy" } | { type: "delete"; onDelete: (tag: string) => void }; + to?: string; + tooltip?: string; +}) { const tagResult = useMemo(() => splitTag(tag), [tag]); const [isHovered, setIsHovered] = useState(false); @@ -57,7 +67,11 @@ export function RunTag({ tag, to, tooltip }: { tag: string; to?: string; tooltip return (
setIsHovered(false)}> {tagContent} - + {action.type === "delete" ? ( + + ) : ( + + )}
); } @@ -105,6 +119,45 @@ function CopyButton({ textToCopy, isHovered }: { textToCopy: string; isHovered: ); } +function DeleteButton({ + tag, + onDelete, + isHovered, +}: { + tag: string; + onDelete: (tag: string) => void; + isHovered: boolean; +}) { + const handleDelete = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onDelete(tag); + }, + [tag, onDelete] + ); + + return ( + e.stopPropagation()} + className={cn( + "absolute -right-6 top-0 z-10 size-6 items-center justify-center rounded-r-sm border-y border-r border-charcoal-650 bg-charcoal-750", + isHovered ? "flex" : "hidden", + "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-rose-400" + )} + > + + + } + content="Remove tag" + disableHoverableContent + /> + ); +} + /** Takes a string and turns it into a tag * * If the string has 12 or fewer alpha characters followed by an underscore or colon then we return an object with a key and value diff --git a/apps/webapp/app/components/runs/v3/RunTagInput.tsx b/apps/webapp/app/components/runs/v3/RunTagInput.tsx new file mode 100644 index 0000000000..e6fb96c323 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/RunTagInput.tsx @@ -0,0 +1,111 @@ +import { useCallback, useState, type KeyboardEvent } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Input } from "~/components/primitives/Input"; +import { RunTag } from "./RunTag"; + +interface TagInputProps { + id?: string; // used for the hidden input for form submission + name?: string; // used for the hidden input for form submission + defaultTags?: string[]; + placeholder?: string; + variant?: "small" | "medium"; + onTagsChange?: (tags: string[]) => void; +} + +export function RunTagInput({ + id, + name, + defaultTags = [], + placeholder = "Type and press Enter to add tags", + variant = "small", + onTagsChange, +}: TagInputProps) { + const [tags, setTags] = useState(defaultTags); + const [inputValue, setInputValue] = useState(""); + + const addTag = useCallback( + (tagText: string) => { + const trimmedTag = tagText.trim(); + if (trimmedTag && !tags.includes(trimmedTag)) { + const newTags = [...tags, trimmedTag]; + setTags(newTags); + onTagsChange?.(newTags); + } + setInputValue(""); + }, + [tags, onTagsChange] + ); + + const removeTag = useCallback( + (tagToRemove: string) => { + const newTags = tags.filter((tag) => tag !== tagToRemove); + setTags(newTags); + onTagsChange?.(newTags); + }, + [tags, onTagsChange] + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + addTag(inputValue); + } else if (e.key === "Backspace" && inputValue === "" && tags.length > 0) { + removeTag(tags[tags.length - 1]); + } + }, + [inputValue, addTag, removeTag, tags] + ); + + return ( +
+ + + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + variant={variant} + /> + + {tags.length > 0 && ( +
+ + {tags.map((tag, i) => ( + + + + ))} + +
+ )} +
+ ); +} 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 c9d59a126b..f73bfb6b0f 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,17 +1,17 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { BeakerIcon } from "@heroicons/react/20/solid"; -import { Form, useActionData, useSubmit } from "@remix-run/react"; +import { RectangleStackIcon } from "@heroicons/react/20/solid"; +import { Form, useActionData, useSubmit, useFetcher } from "@remix-run/react"; import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; -import { type TaskRunStatus } from "@trigger.dev/database"; import { useCallback, useEffect, useRef, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { TaskIcon } from "~/assets/icons/TaskIcon"; import { JSONEditor } from "~/components/code/JSONEditor"; -import { EnvironmentCombo, EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; +import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; +import { Badge } from "~/components/primitives/Badge"; import { Button } from "~/components/primitives/Buttons"; -import { Callout } from "~/components/primitives/Callout"; import { DateField } from "~/components/primitives/DateField"; -import { DateTime } from "~/components/primitives/DateTime"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormError } from "~/components/primitives/FormError"; import { Header2 } from "~/components/primitives/Headers"; @@ -19,17 +19,16 @@ 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 { DurationPicker } from "~/components/primitives/DurationPicker"; import { Paragraph } from "~/components/primitives/Paragraph"; -import { RadioButtonCircle } from "~/components/primitives/RadioButton"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "~/components/primitives/Resizable"; -import { Select } from "~/components/primitives/Select"; +import { Select, SelectItem } from "~/components/primitives/Select"; import { TabButton, TabContainer } from "~/components/primitives/Tabs"; import { TextLink } from "~/components/primitives/TextLink"; -import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus"; import { TimezoneList } from "~/components/scheduled/timezones"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useSearchParams } from "~/hooks/useSearchParam"; @@ -53,7 +52,7 @@ import { docsPath, v3RunSpanPath, v3TaskParamsSchema, v3TestPath } from "~/utils import { TestTaskService } from "~/v3/services/testTask.server"; import { OutOfEntitlementError } from "~/v3/services/triggerTask.server"; import { TestTaskData } from "~/v3/testTask"; - +import { RunTagInput } from "~/components/runs/v3/RunTagInput"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); const { projectParam, organizationSlug, envParam, taskParam } = v3TaskParamsSchema.parse(params); @@ -189,7 +188,6 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa const tab = value("tab"); //form submission - const submit = useSubmit(); const lastSubmission = useActionData(); //recent runs @@ -216,43 +214,62 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa const currentMetadataJson = useRef(defaultMetadataJson); - const submitForm = useCallback( - (e: React.FormEvent) => { - submit( - { - triggerSource: "STANDARD", - payload: currentPayloadJson.current, - metadata: currentMetadataJson.current, - taskIdentifier: task.taskIdentifier, - environmentId: environment.id, - }, - { - action: "", - method: "post", - } - ); - e.preventDefault(); + const fetcher = useFetcher(); + const [ + form, + { + environmentId, + payload, + metadata, + taskIdentifier, + delaySeconds, + ttlSeconds, + idempotencyKey, + idempotencyKeyTTLSeconds, + queue, + concurrencyKey, + maxAttempts, + maxDurationSeconds, + triggerSource, + tags, + version, }, - [currentPayloadJson, currentMetadataJson, task] - ); - - const [form, { environmentId, payload }] = useForm({ + ] = useForm({ id: "test-task", // TODO: type this 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" }); + }, onValidate({ formData }) { return parse(formData, { schema: TestTaskData }); }, }); + // fetch them in the loader + const dummyQueues = [ + { value: "default", label: "default", type: "task" as const, disabled: false }, + { value: "high-priority", label: "high-priority", type: "custom" as const, disabled: false }, + { value: "background", label: "background", type: "custom" as const, disabled: false }, + { value: "paused-queue", label: "paused-queue", type: "task" as const, disabled: true }, + { + value: "email-processing", + label: "email-processing", + type: "custom" as const, + disabled: false, + }, + ]; + return ( -
submitForm(e)} - > - + + + +
@@ -321,18 +338,118 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa
- - { - const run = runs.find((r) => r.id === id); - if (!run) return; - setPayload(run.payload); - run.seedMetadata && setMetadata(run.seedMetadata); - setSelectedCodeSampleId(id); - }} - /> + +
+
+ Options +
+
+ + + + {delaySeconds.error} + + + + + {ttlSeconds.error} + + + + + {idempotencyKey.error} + + + + + + {idempotencyKeyTTLSeconds.error} + + + + + + + + + + {concurrencyKey.error} + + + + + + + + + {maxDurationSeconds.error} + + + + + {tags.error} + + + + + {version.error} + + {form.error} +
+
@@ -423,118 +540,98 @@ function ScheduledTaskForm({ {...conform.input(environmentId, { type: "hidden" })} value={environment.id} /> - - -
-
- - - - setTimestampValue(val)} - granularity="second" - showNowButton - variant="medium" - utc - /> - - This is the timestamp of the CRON, it will come through to your run in the - payload. - - {timestamp.error} - - - - - setLastTimestampValue(val)} - granularity="second" - showNowButton - showClearButton - variant="medium" - utc - /> - - This is the timestamp of the previous run. You can use this in your code to find - new data since the previous run. This can be undefined if there hasn't been a - previous run. - - {lastTimestamp.error} - - - - - - The Timestamp and Last timestamp are in UTC so this just changes the timezone - string that comes through in the payload. - - {timezone.error} - - - - setExternalIdValue(e.target.value)} - /> - - Optionally, you can specify your own IDs (like a user ID) and then use it inside - the run function of your task. This allows you to have per-user CRON tasks.{" "} - Read the docs. - - {externalId.error} - -
-
-
- - - { - const run = runs.find((r) => r.id === id); - if (!run) return; - setSelectedCodeSampleId(id); - setTimestampValue(run.payload.timestamp); - setLastTimestampValue(run.payload.lastTimestamp); - setExternalIdValue(run.payload.externalId); - }} - /> - -
+
+
+ + + + setTimestampValue(val)} + granularity="second" + showNowButton + variant="medium" + utc + /> + + This is the timestamp of the CRON, it will come through to your run in the payload. + + {timestamp.error} + + + + + setLastTimestampValue(val)} + granularity="second" + showNowButton + showClearButton + variant="medium" + utc + /> + + This is the timestamp of the previous run. You can use this in your code to find new + data since the previous run. This can be undefined if there hasn't been a previous + run. + + {lastTimestamp.error} + + + + + + The Timestamp and Last timestamp are in UTC so this just changes the timezone string + that comes through in the payload. + + {timezone.error} + + + + setExternalIdValue(e.target.value)} + /> + + Optionally, you can specify your own IDs (like a user ID) and then use it inside the + run function of your task. This allows you to have per-user CRON tasks.{" "} + Read the docs. + + {externalId.error} + +
+
@@ -554,55 +651,3 @@ function ScheduledTaskForm({ ); } - -function RecentPayloads({ - runs, - selectedId, - onSelected, -}: { - runs: { - id: string; - createdAt: Date; - number: number; - status: TaskRunStatus; - }[]; - selectedId?: string; - onSelected: (id: string) => void; -}) { - return ( -
-
- Recent payloads -
- {runs.length === 0 ? ( - - Recent payloads will show here once you've completed a Run. - - ) : ( -
- {runs.map((run) => ( - - ))} -
- )} -
- ); -} diff --git a/apps/webapp/app/v3/services/testTask.server.ts b/apps/webapp/app/v3/services/testTask.server.ts index 740f64156e..f799acc2d4 100644 --- a/apps/webapp/app/v3/services/testTask.server.ts +++ b/apps/webapp/app/v3/services/testTask.server.ts @@ -1,6 +1,6 @@ import { stringifyIO } from "@trigger.dev/core/v3"; -import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; -import { TestTaskData } from "../testTask"; +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { type TestTaskData } from "../testTask"; import { BaseService } from "./baseService.server"; import { TriggerTaskService } from "./triggerTask.server"; @@ -15,6 +15,17 @@ export class TestTaskService extends BaseService { options: { test: true, metadata: data.metadata, + delay: data.delaySeconds ? new Date(Date.now() + data.delaySeconds * 1000) : undefined, + ttl: data.ttlSeconds, + idempotencyKey: data.idempotencyKey, + idempotencyKeyTTL: data.idempotencyKeyTTLSeconds + ? `${data.idempotencyKeyTTLSeconds}s` + : undefined, + queue: data.queue ? { name: data.queue } : undefined, + concurrencyKey: data.concurrencyKey, + maxAttempts: data.maxAttempts, + maxDuration: data.maxDurationSeconds, + tags: data.tags, }, }); diff --git a/apps/webapp/app/v3/testTask.ts b/apps/webapp/app/v3/testTask.ts index 764f6b09f7..81e838ea43 100644 --- a/apps/webapp/app/v3/testTask.ts +++ b/apps/webapp/app/v3/testTask.ts @@ -56,6 +56,43 @@ export const TestTaskData = z z.object({ taskIdentifier: z.string(), environmentId: z.string(), + delaySeconds: z + .number() + .min(0) + .optional() + .transform((val) => (val === 0 ? undefined : val)), + ttlSeconds: z + .number() + .min(0) + .optional() + .transform((val) => (val === 0 ? undefined : val)), + idempotencyKey: z.string().optional(), + idempotencyKeyTTLSeconds: z + .number() + .min(0) + .optional() + .transform((val) => (val === 0 ? undefined : val)), + queue: z.string().optional(), + concurrencyKey: z.string().optional(), + maxAttempts: z.number().min(1).optional(), + maxDurationSeconds: z + .number() + .min(0) + .optional() + .transform((val) => (val === 0 ? undefined : val)), + tags: z + .string() + .optional() + .transform((val) => { + if (!val || val.trim() === "") { + return undefined; + } + return val + .split(",") + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); + }), + version: z.string().optional(), }) );