Files
2026-07-13 13:32:57 +08:00

506 lines
20 KiB
TypeScript

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<unknown>;
}) {
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<string>(schedule?.timezone ?? "UTC");
const isUtc = selectedTimezone === "UTC";
const [cronPattern, setCronPattern] = useState<string>(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 (
<FormComponent
method="post"
action={`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/schedules/new`}
{...getFormProps(form)}
className="grid h-full max-h-full grid-rows-[2.5rem_1fr_auto] overflow-hidden bg-background-bright"
>
<div className="mx-3 flex min-w-0 items-center justify-between gap-2 overflow-hidden border-b border-grid-dimmed">
<Header2 className="truncate">
{schedule?.friendlyId
? "Edit schedule"
: defaultTaskIdentifier
? `New schedule for ${defaultTaskIdentifier}`
: "New schedule"}
</Header2>
</div>
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="p-3">
{submitFetcher ? <input type="hidden" name="_format" value="json" /> : null}
{schedule && <input type="hidden" name="friendlyId" value={schedule.friendlyId} />}
<Fieldset>
{(() => {
// Lock the task via hidden input when it's implied (sheet on a task page, or editing).
const lockedTaskIdentifier = schedule?.taskIdentifier ?? defaultTaskIdentifier;
return lockedTaskIdentifier ? (
<input type="hidden" name={taskIdentifier.name} value={lockedTaskIdentifier} />
) : (
<InputGroup>
<Label htmlFor={taskIdentifier.id}>Task</Label>
<Select
{...getSelectProps(taskIdentifier)}
placeholder="Select a task"
defaultValue={schedule?.taskIdentifier}
heading={"Filter..."}
items={possibleTasks}
filter={(task, search) => task.toLowerCase().includes(search.toLowerCase())}
dropdownIcon
variant="tertiary/medium"
>
{(matches) => (
<>
{matches?.map((task) => (
<SelectItem key={task} value={task}>
{task}
</SelectItem>
))}
</>
)}
</Select>
<FormError id={taskIdentifier.errorId}>{taskIdentifier.errors}</FormError>
</InputGroup>
);
})()}
{showGenerateField && <AIGeneratedCronField onSuccess={setCronPattern} />}
<InputGroup>
<Label
htmlFor={cron.id}
tooltip={
<div className="spacy-y-3">
<Paragraph variant="extra-small">We support this CRON format:</Paragraph>
<code>
<pre>{cronFormat}</pre>
</code>
<Paragraph variant="extra-small">"L" means the last.</Paragraph>
</div>
}
>
CRON pattern (UTC)
</Label>
<Input
{...getInputProps(cron, { type: "text" })}
placeholder="? ? ? ? ?"
required={true}
value={cronPattern}
onChange={(e) => {
setCronPattern(e.target.value);
}}
/>
{cronPatternResult === undefined ? (
<Hint>Enter a CRON pattern or use natural language above.</Hint>
) : cronPatternResult.isValid ? (
<ValidCronMessage isValid={true} message={`${cronPatternResult.description}.`} />
) : (
<ValidCronMessage isValid={false} message={cronPatternResult.error} />
)}
</InputGroup>
<InputGroup>
<Label htmlFor={timezone.id}>Timezone</Label>
<Select
{...getSelectProps(timezone)}
placeholder="Select a timezone"
defaultValue={selectedTimezone}
value={selectedTimezone}
setValue={(e) => {
if (Array.isArray(e)) return;
setSelectedTimezone(e);
}}
items={possibleTimezones}
filter={{ keys: [(item) => item.replace(/\//g, " ").replace(/_/g, " ")] }}
dropdownIcon
variant="tertiary/medium"
>
{(matches) => <TimezoneList timezones={matches} />}
</Select>
<Hint>
{isUtc
? "UTC will not change with daylight savings time."
: "This will automatically adjust for daylight savings time."}
</Hint>
<FormError id={timezone.errorId}>{timezone.errors}</FormError>
</InputGroup>
{nextRuns !== undefined && (
<div className="flex flex-col gap-1">
<Header3>Next 5 runs</Header3>
<Table>
<TableHeader>
<TableRow>
{!isUtc && <TableHeaderCell>{selectedTimezone}</TableHeaderCell>}
<TableHeaderCell>UTC</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{nextRuns.map((run, index) => (
<TableRow key={index}>
{!isUtc && (
<TableCell>
<DateTime date={run} timeZone={selectedTimezone} />
</TableCell>
)}
<TableCell>
<DateTime date={run} timeZone="UTC" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<InputGroup>
<Label>Environment</Label>
<div className="flex flex-wrap items-center gap-2">
{/* This first condition supports old schedules where we let you have multiple environments */}
{schedule && schedule?.environments.length > 1 ? (
possibleEnvironments.map((environment) => (
<CheckboxWithLabel
key={environment.id}
id={environment.id}
value={environment.id}
name="environments"
type="radio"
label={
<span
className={cn("text-xs uppercase", environmentTextClassName(environment))}
>
{environmentTitle(environment, environment.userName)}
</span>
}
defaultChecked={
schedule?.instances.find((i) => i.environmentId === environment.id) !==
undefined
}
variant="button"
/>
))
) : (
<>
<input type="hidden" name="environments" value={environment.id} />
<EnvironmentCombo environment={environment} />
</>
)}
</div>
{environment.type === "DEVELOPMENT" && (
<Hint>
Note that scheduled tasks in dev environments will only run while you are
connected with the dev CLI.
</Hint>
)}
<FormError id={environments.errorId}>{environments.errors}</FormError>
</InputGroup>
<InputGroup>
<Label required={false} htmlFor={externalId.id}>
External ID
</Label>
<Input
{...getInputProps(externalId, { type: "text" })}
placeholder="Optionally specify your own ID, e.g. user id"
defaultValue={schedule?.externalId ?? undefined}
/>
<Hint>
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.{" "}
<TextLink to={docsPath("v3/tasks-scheduled")}>Read the docs.</TextLink>
</Hint>
<FormError id={externalId.errorId}>{externalId.errors}</FormError>
</InputGroup>
<InputGroup>
<Label required={false} htmlFor={deduplicationKey.id}>
Deduplication key
</Label>
<Input
{...getInputProps(deduplicationKey, { type: "text" })}
disabled={schedule !== undefined}
defaultValue={
schedule?.userProvidedDeduplicationKey ? schedule?.deduplicationKey : undefined
}
/>
{schedule && (
<Paragraph variant="small">
You can't edit the Deduplication key on an existing schedule.
</Paragraph>
)}
<Hint>
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.
</Hint>
<FormError id={deduplicationKey.errorId}>{deduplicationKey.errors}</FormError>
</InputGroup>
<FormError>{form.errors}</FormError>
</Fieldset>
</div>
</div>
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2 py-2">
<div className="flex items-center gap-4">
{onCancel ? (
<Button variant="secondary/small" onClick={onCancel} type="button">
Cancel
</Button>
) : (
<LinkButton
to={`${v3EnvironmentPath(organization, project, environment)}${location.search}`}
variant="secondary/small"
>
Cancel
</LinkButton>
)}
</div>
<div className="flex items-center gap-4">
<Button
variant="primary/small"
type="submit"
disabled={isLoading}
shortcut={{ key: "enter", modifiers: ["mod"], enabledOnInputElements: true }}
LeadingIcon={isLoading ? Spinner : undefined}
>
{buttonText(mode, isLoading)}
</Button>
</div>
</div>
</FormComponent>
);
}
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 (
<Paragraph variant="small">
<span className="mr-1">
{isValid ? (
<CheckIcon className="-mt-0.5 mr-1 inline-block h-4 w-4 text-success" />
) : (
<XMarkIcon className="-mt-0.5 mr-1 inline-block h-4 w-4 text-error" />
)}
<span className={isValid ? "text-success" : "text-error"}>
{isValid ? "Valid pattern:" : "Invalid pattern:"}
</span>
</span>
<span>{message}</span>
</Paragraph>
);
}