import { getFormProps, getInputProps, getSelectProps, useForm } from "@conform-to/react"; import { parseWithZod } from "@conform-to/zod"; import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { type FetcherWithComponents, Form, useActionData, useLocation, useNavigation, } from "@remix-run/react"; import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { parseExpression } from "cron-parser"; import cronstrue from "cronstrue"; import { useState } from "react"; import { EnvironmentCombo, environmentTextClassName, environmentTitle, } from "~/components/environments/EnvironmentLabel"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; import { DateTime } from "~/components/primitives/DateTime"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormError } from "~/components/primitives/FormError"; import { Header2, Header3 } from "~/components/primitives/Headers"; import { Hint } from "~/components/primitives/Hint"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Select, SelectItem } from "~/components/primitives/Select"; import { Spinner } from "~/components/primitives/Spinner"; import { Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow, } from "~/components/primitives/Table"; import { TextLink } from "~/components/primitives/TextLink"; import { TimezoneList } from "~/components/scheduled/timezones"; import { prisma } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import type { EditableScheduleElements } from "~/presenters/v3/EditSchedulePresenter.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { EnvironmentParamSchema, docsPath, v3EnvironmentPath } from "~/utils/pathBuilder"; import { CronPattern, UpsertSchedule } from "~/v3/schedules"; import { UpsertTaskScheduleService } from "~/v3/services/upsertTaskSchedule.server"; import { AIGeneratedCronField } from "../resources.orgs.$organizationSlug.projects.$projectParam.schedules.new.natural-language"; const cronFormat = `* * * * * ┬ ┬ ┬ ┬ ┬ │ │ │ │ | │ │ │ │ └ day of week (0 - 7, 1L - 7L) (0 or 7 is Sun) │ │ │ └───── month (1 - 12) │ │ └────────── day of month (1 - 31, L) │ └─────────────── hour (0 - 23) └──────────────────── minute (0 - 59)`; export const action = async ({ request, params }: ActionFunctionArgs) => { const userId = await requireUserId(request); const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); const formData = await request.formData(); const submission = parseWithZod(formData, { schema: UpsertSchedule }); if (submission.status !== "success") { return json(submission.reply()); } // `_format=json` → return JSON instead of redirecting; caller toasts. const wantsJson = formData.get("_format") === "json"; try { //first check that the user has access to the project const project = await prisma.project.findUnique({ where: { slug: projectParam, organization: { members: { some: { userId, }, }, }, }, select: { id: true }, }); if (!project) { throw new Error("Project not found"); } const createSchedule = new UpsertTaskScheduleService(); const result = await createSchedule.call(project.id, submission.value); const message = submission.value?.friendlyId === result.id ? "Schedule updated" : "Schedule created"; if (wantsJson) { return json({ ok: true as const, message }); } return redirectWithSuccessMessage( v3EnvironmentPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }), request, message ); } catch (error: any) { logger.error("Failed to create schedule", error); const errorMessage = `Something went wrong. Please try again.`; if (wantsJson) { return json({ ok: false as const, message: errorMessage }, { status: 500 }); } return redirectWithErrorMessage( v3EnvironmentPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }), request, errorMessage ); } }; type CronPatternResult = | { isValid: true; description: string; } | { isValid: false; error: string; }; export function UpsertScheduleForm({ schedule, possibleTasks, possibleEnvironments, possibleTimezones, showGenerateField, defaultTaskIdentifier, onCancel, submitFetcher, }: EditableScheduleElements & { showGenerateField: boolean; /** Pre-fills the Task field on new schedules. Ignored when editing. */ defaultTaskIdentifier?: string; /** When set, Cancel calls back instead of navigating. */ onCancel?: () => void; /** Submits via this fetcher with `_format=json` so the host can toast/close itself. */ submitFetcher?: FetcherWithComponents; }) { const actionData = useActionData(); // Only feed conform-shaped data (`status`) to `useForm` — `{ ok, message }` // envelopes lack it and crash conform. const fetcherSubmission = submitFetcher?.data && typeof submitFetcher.data === "object" && "status" in submitFetcher.data ? submitFetcher.data : undefined; const lastSubmission = submitFetcher ? fetcherSubmission : actionData; const [selectedTimezone, setSelectedTimezone] = useState(schedule?.timezone ?? "UTC"); const isUtc = selectedTimezone === "UTC"; const [cronPattern, setCronPattern] = useState(schedule?.cron ?? ""); const navigation = useNavigation(); const isLoading = submitFetcher ? submitFetcher.state !== "idle" : navigation.state !== "idle"; const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); const location = useLocation(); const [form, { taskIdentifier, cron, timezone, externalId, environments, deduplicationKey }] = useForm({ // Disambiguate per-schedule so both sheets (create + edit) can // coexist without duplicate DOM ids breaking `htmlFor` / conform. id: schedule?.friendlyId ? `edit-schedule-${schedule.friendlyId}` : "create-schedule", // TODO: type this lastResult: lastSubmission as any, shouldRevalidate: "onSubmit", onValidate({ formData }) { return parseWithZod(formData, { schema: UpsertSchedule }); }, }); let cronPatternResult: CronPatternResult | undefined = undefined; let nextRuns: Date[] | undefined = undefined; if (cronPattern !== "") { const result = CronPattern.safeParse(cronPattern); if (!result.success) { cronPatternResult = { isValid: false, error: result.error.errors[0].message, }; } else { try { const expression = parseExpression( cronPattern, isUtc ? { utc: true } : { tz: selectedTimezone } ); cronPatternResult = { isValid: true, description: cronstrue.toString(cronPattern), }; nextRuns = Array.from({ length: 5 }, (_, i) => { const utc = expression.next().toDate(); return utc; }); } catch (e) { cronPatternResult = { isValid: false, error: e instanceof Error ? e.message : JSON.stringify(e), }; } } } const mode = schedule ? "edit" : "new"; const FormComponent = submitFetcher?.Form ?? Form; return (
{schedule?.friendlyId ? "Edit schedule" : defaultTaskIdentifier ? `New schedule for ${defaultTaskIdentifier}` : "New schedule"}
{submitFetcher ? : null} {schedule && }
{(() => { // Lock the task via hidden input when it's implied (sheet on a task page, or editing). const lockedTaskIdentifier = schedule?.taskIdentifier ?? defaultTaskIdentifier; return lockedTaskIdentifier ? ( ) : ( {taskIdentifier.errors} ); })()} {showGenerateField && }
} > CRON pattern (UTC) { setCronPattern(e.target.value); }} /> {cronPatternResult === undefined ? ( Enter a CRON pattern or use natural language above. ) : cronPatternResult.isValid ? ( ) : ( )} {isUtc ? "UTC will not change with daylight savings time." : "This will automatically adjust for daylight savings time."} {timezone.errors} {nextRuns !== undefined && (
Next 5 runs {!isUtc && {selectedTimezone}} UTC {nextRuns.map((run, index) => ( {!isUtc && ( )} ))}
)}
{/* This first condition supports old schedules where we let you have multiple environments */} {schedule && schedule?.environments.length > 1 ? ( possibleEnvironments.map((environment) => ( {environmentTitle(environment, environment.userName)} } defaultChecked={ schedule?.instances.find((i) => i.environmentId === environment.id) !== undefined } variant="button" /> )) ) : ( <> )}
{environment.type === "DEVELOPMENT" && ( Note that scheduled tasks in dev environments will only run while you are connected with the dev CLI. )} {environments.errors}
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.errors} {schedule && ( You can't edit the Deduplication key on an existing schedule. )} Optionally specify a key, you can only create one schedule with this key. This is very useful when using the SDK and you don't want to create duplicate schedules for a user. {deduplicationKey.errors} {form.errors}
{onCancel ? ( ) : ( Cancel )}
); } function buttonText(mode: "edit" | "new", isLoading: boolean) { switch (mode) { case "edit": return isLoading ? "Updating schedule" : "Update schedule"; case "new": return isLoading ? "Creating schedule" : "Create schedule"; } } function ValidCronMessage({ isValid, message }: { isValid: boolean; message: string }) { return ( {isValid ? ( ) : ( )} {isValid ? "Valid pattern:" : "Invalid pattern:"} {message} ); }