diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index d36e947286..c52991d543 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -198,7 +198,7 @@ export function SessionsNone() { panelClassName="max-w-full" accessory={ diff --git a/apps/webapp/app/components/schedules/ScheduleInspector.tsx b/apps/webapp/app/components/schedules/ScheduleInspector.tsx index c2cc600247..abe25bc178 100644 --- a/apps/webapp/app/components/schedules/ScheduleInspector.tsx +++ b/apps/webapp/app/components/schedules/ScheduleInspector.tsx @@ -6,7 +6,7 @@ import { TrashIcon, } from "@heroicons/react/20/solid"; import { DialogDescription } from "@radix-ui/react-dialog"; -import { Form, useLocation } from "@remix-run/react"; +import { type FetcherWithComponents, Form, useLocation } from "@remix-run/react"; import { type ReactNode } from "react"; import { InlineCode } from "~/components/code/InlineCode"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; @@ -76,9 +76,22 @@ type Props = { * is rendered somewhere else (e.g. in a sheet on a different page). */ actionPath?: string; + /** When set, Edit calls back instead of navigating to the standalone edit page. */ + onEdit?: () => void; + /** Submits enable/disable via this fetcher with `_format=json` so the host stays put. */ + activeToggleFetcher?: FetcherWithComponents; + /** Submits delete via this fetcher with `_format=json` so the host stays put. */ + deleteFetcher?: FetcherWithComponents; }; -export function ScheduleInspector({ schedule, headerActions, actionPath }: Props) { +export function ScheduleInspector({ + schedule, + headerActions, + actionPath, + onEdit, + activeToggleFetcher, + deleteFetcher, +}: Props) { const location = useLocation(); const organization = useOrganization(); const project = useProject(); @@ -91,7 +104,7 @@ export function ScheduleInspector({ schedule, headerActions, actionPath }: Props
@@ -244,30 +257,38 @@ export function ScheduleInspector({ schedule, headerActions, actionPath }: Props
{isImperative && ( -
+
-
- -
+ {(() => { + const ToggleForm = activeToggleFetcher?.Form ?? Form; + const isSubmitting = activeToggleFetcher?.state === "submitting"; + return ( + + {activeToggleFetcher ? : null} + + + ); + })()} @@ -276,31 +297,45 @@ export function ScheduleInspector({ schedule, headerActions, actionPath }: Props Are you sure you want to delete this schedule? This can't be reversed. -
- -
+ {(() => { + const DeleteForm = deleteFetcher?.Form ?? Form; + const isSubmitting = deleteFetcher?.state === "submitting"; + return ( + + {deleteFetcher ? : null} + + + ); + })()}
- - Edit schedule - + {onEdit ? ( + + ) : ( + + Edit schedule… + + )}
)} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route.tsx index 290ea35db8..2407d308fe 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route.tsx @@ -16,7 +16,6 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server"; import { requireUserId } from "~/services/session.server"; import { v3EnvironmentPath, v3ScheduleParams, v3SchedulePath } from "~/utils/pathBuilder"; -import { throwNotFound } from "~/utils/httpErrors"; import { DeleteTaskScheduleService } from "~/v3/services/deleteTaskSchedule.server"; import { SetActiveOnTaskScheduleService } from "~/v3/services/setActiveOnTaskSchedule.server"; @@ -45,11 +44,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { environmentId: environment.id, }); - if (!result) { - throwNotFound("Schedule not found"); - } - - return typedjson({ schedule: result.schedule }); + // Return null (not a 404 throw) so fetcher-driven hosts (e.g. the sheet + // running this loader after a delete-in-flight) don't surface a + // page-level error boundary. The standalone Page below renders a + // not-found message when `schedule` is null. + return typedjson({ schedule: result?.schedule ?? null }); }; const schema = z.discriminatedUnion("action", [ @@ -76,6 +75,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return json(submission); } + // `_format=json` → return JSON instead of redirecting; caller stays put. + const wantsJson = formData.get("_format") === "json"; + const project = await prisma.project.findFirst({ where: { slug: projectParam, @@ -83,6 +85,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }); if (!project) { + const message = `No project found with slug ${projectParam}`; + if (wantsJson) { + return json({ ok: false as const, message }, { status: 404 }); + } return redirectWithErrorMessage( v3SchedulePath( { slug: organizationSlug }, @@ -91,7 +97,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { { friendlyId: scheduleParam } ), request, - `No project found with slug ${projectParam}` + message ); } @@ -104,12 +110,21 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { userId, friendlyId: scheduleParam, }); + if (wantsJson) { + return json({ ok: true as const, message: `${scheduleParam} deleted` }); + } return redirectWithSuccessMessage( v3EnvironmentPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }), request, `${scheduleParam} deleted` ); } catch (e) { + const message = `${scheduleParam} could not be deleted: ${ + e instanceof Error ? e.message : JSON.stringify(e) + }`; + if (wantsJson) { + return json({ ok: false as const, message }, { status: 500 }); + } return redirectWithErrorMessage( v3SchedulePath( { slug: organizationSlug }, @@ -118,9 +133,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { { friendlyId: scheduleParam } ), request, - `${scheduleParam} could not be deleted: ${ - e instanceof Error ? e.message : JSON.stringify(e) - }` + message ); } } @@ -135,6 +148,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { friendlyId: scheduleParam, active, }); + if (wantsJson) { + return json({ ok: true as const, active }); + } return redirectWithSuccessMessage( v3SchedulePath( { slug: organizationSlug }, @@ -146,6 +162,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { `${scheduleParam} ${active ? "enabled" : "disabled"}` ); } catch (e) { + const message = e instanceof Error ? e.message : JSON.stringify(e); + if (wantsJson) { + return json({ ok: false as const, message }, { status: 500 }); + } return redirectWithErrorMessage( v3SchedulePath( { slug: organizationSlug }, @@ -154,9 +174,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { { friendlyId: scheduleParam } ), request, - `${scheduleParam} could not be ${active ? "enabled" : "disabled"}: ${ - e instanceof Error ? e.message : JSON.stringify(e) - }` + `${scheduleParam} could not be ${active ? "enabled" : "disabled"}: ${message}` ); } } @@ -170,6 +188,20 @@ export default function Page() { const project = useProject(); const environment = useEnvironment(); + if (!schedule) { + return ( +
+

Schedule not found.

+ + Back to tasks + +
+ ); + } + return ( Sessions docs diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index c73fa25304..d82ebea7a5 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -1,9 +1,9 @@ -import { type MetaFunction } from "@remix-run/react"; +import { type MetaFunction, useFetcher, useRevalidator } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { TypedAwait, typeddefer, useTypedFetcher, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; -import { PlusIcon } from "@heroicons/react/20/solid"; +import { BookOpenIcon, PlusIcon } from "@heroicons/react/20/solid"; import { BeakerIcon } from "~/assets/icons/BeakerIcon"; import { ClockIcon } from "~/assets/icons/ClockIcon"; import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; @@ -24,11 +24,16 @@ import { import { ScheduleLimitActions } from "~/components/schedules/ScheduleLimitActions"; import { SchedulesUsageBar } from "~/components/schedules/SchedulesUsageBar"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import { InlineCode } from "~/components/code/InlineCode"; import { CopyableText } from "~/components/primitives/CopyableText"; +import { PaginationControls } from "~/components/primitives/Pagination"; +import { TabButton, TabContainer } from "~/components/primitives/Tabs"; +import { useToast } from "~/components/primitives/Toast"; import { DateTime, RelativeDateTime } from "~/components/primitives/DateTime"; -import { Header2, Header3 } from "~/components/primitives/Headers"; +import { Header2 } from "~/components/primitives/Headers"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; +import { InfoPanel } from "~/components/primitives/InfoPanel"; import * as Property from "~/components/primitives/PropertyTable"; import { Sheet, SheetContent } from "~/components/primitives/SheetV3"; import { ScheduleInspector } from "~/components/schedules/ScheduleInspector"; @@ -63,6 +68,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; import { ScheduleListPresenter } from "~/presenters/v3/ScheduleListPresenter.server"; import type { loader as scheduleDetailLoader } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route"; +import type { loader as scheduleEditLoader } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.edit.$scheduleParam/route"; import type { loader as scheduleNewLoader } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route"; import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route"; import { @@ -73,9 +79,11 @@ import { import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { requireUser } from "~/services/session.server"; import { + docsPath, EnvironmentParamSchema, v3BillingPath, v3CreateBulkActionPath, + v3EditSchedulePath, v3EnvironmentPath, v3NewSchedulePath, v3RunsPath, @@ -156,6 +164,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { environmentId: environment.id, tasks: [task.slug], page: schedulesPage, + pageSize: 25, }) .catch(() => null); @@ -202,10 +211,7 @@ export default function Page() { const closeSchedule = useCallback(() => search.del("schedule"), [search]); const isCreatingSchedule = search.has("createSchedule"); - const openCreateSchedule = useCallback( - () => search.replace({ createSchedule: "1" }), - [search] - ); + const openCreateSchedule = useCallback(() => search.replace({ createSchedule: "1" }), [search]); const closeCreateSchedule = useCallback(() => search.del("createSchedule"), [search]); // Schedules add-on / quota state — drives the bottom usage bar and the @@ -240,9 +246,9 @@ export default function Page() {
{/* Top bar — title on the left; actions + TimeFilter + pagination on the right. h-10 matches the right-hand sidebar header height. */} -
+
Runs -
+
void; }) { const fetcher = useTypedFetcher(); + // Embedded create — stays on this page via `_format=json`. + const createFetcher = useFetcher<{ ok: boolean; message?: string }>(); + const toast = useToast(); + const revalidator = useRevalidator(); + // `useRevalidator()` and `onClose` change identity every render — guard + // against the dep churn so we only handle each response once. + const handledCreateRef = useRef(null); const newPath = v3NewSchedulePath(organization, project, environment); useEffect(() => { if (open) fetcher.load(newPath); }, [open, newPath]); + // Toast + close + revalidate so the new schedule appears. + useEffect(() => { + const data = createFetcher.data; + if (createFetcher.state !== "idle" || !data) return; + if (handledCreateRef.current === data) return; + handledCreateRef.current = data; + if (data.ok) { + toast.success(data.message ?? "Schedule created"); + revalidator.revalidate(); + onClose(); + } else if (data.message) { + toast.error(data.message); + } + }, [createFetcher.state, createFetcher.data, toast, revalidator, onClose]); + const data = fetcher.data; const isLoading = fetcher.state === "loading" || (open && !data); @@ -512,6 +540,8 @@ function CreateScheduleSheet({ possibleTimezones={data.possibleTimezones} showGenerateField={data.showGenerateField} defaultTaskIdentifier={defaultTaskIdentifier} + onCancel={onClose} + submitFetcher={createFetcher} /> )} @@ -532,17 +562,111 @@ function ScheduleSheet({ environment: ReturnType; onClose: () => void; }) { - const fetcher = useTypedFetcher(); + const detailFetcher = useTypedFetcher(); + const editFetcher = useTypedFetcher(); + // Embedded enable/disable — stays in the sheet via `_format=json`. + const activeToggleFetcher = useFetcher<{ ok: boolean; active?: boolean; message?: string }>(); + // Embedded update submission — same idea. + const updateFetcher = useFetcher<{ ok: boolean; message?: string }>(); + // Embedded delete submission — same idea. + const deleteFetcher = useFetcher<{ ok: boolean; message?: string }>(); + const toast = useToast(); + const revalidator = useRevalidator(); + // Dedupe response handling against unstable deps (revalidator/onClose). + const handledToggleRef = useRef(null); + const handledUpdateRef = useRef(null); + const handledDeleteRef = useRef(null); + const [mode, setMode] = useState<"inspect" | "edit">("inspect"); + const detailPath = openScheduleId ? v3SchedulePath(organization, project, environment, { friendlyId: openScheduleId }) : undefined; + const editPath = openScheduleId + ? v3EditSchedulePath(organization, project, environment, { friendlyId: openScheduleId }) + : undefined; + + // Always reopen in inspect mode. + useEffect(() => { + setMode("inspect"); + }, [openScheduleId]); useEffect(() => { - if (detailPath) fetcher.load(detailPath); + if (detailPath) detailFetcher.load(detailPath); }, [detailPath]); - const schedule = fetcher.data?.schedule; - const isLoading = fetcher.state === "loading" || (!!openScheduleId && !schedule); + useEffect(() => { + if (mode === "edit" && editPath) editFetcher.load(editPath); + }, [mode, editPath]); + + // Reload inspector data so Enable/Disable label flips; revalidate the + // route loader so the sidebar's list/Overview stay in sync; toast on error. + useEffect(() => { + const data = activeToggleFetcher.data; + if (activeToggleFetcher.state !== "idle" || !data) return; + if (handledToggleRef.current === data) return; + handledToggleRef.current = data; + if (data.ok) { + if (detailPath) detailFetcher.load(detailPath); + revalidator.revalidate(); + } else if (data.message) { + toast.error(data.message); + } + }, [activeToggleFetcher.state, activeToggleFetcher.data, detailPath, toast, revalidator]); + + // Toast + back to inspect + reload + revalidate so both the inspector + // and the sidebar reflect the update. + useEffect(() => { + const data = updateFetcher.data; + if (updateFetcher.state !== "idle" || !data) return; + if (handledUpdateRef.current === data) return; + handledUpdateRef.current = data; + if (data.ok) { + toast.success(data.message ?? "Schedule updated"); + setMode("inspect"); + if (detailPath) detailFetcher.load(detailPath); + revalidator.revalidate(); + } else if (data.message) { + toast.error(data.message); + } + }, [updateFetcher.state, updateFetcher.data, detailPath, toast, revalidator]); + + // Toast + close + revalidate so the deleted row disappears. + useEffect(() => { + const data = deleteFetcher.data; + if (deleteFetcher.state !== "idle" || !data) return; + if (handledDeleteRef.current === data) return; + handledDeleteRef.current = data; + if (data.ok) { + toast.success(data.message ?? "Schedule deleted"); + revalidator.revalidate(); + onClose(); + } else if (data.message) { + toast.error(data.message); + } + }, [deleteFetcher.state, deleteFetcher.data, toast, revalidator, onClose]); + + const schedule = detailFetcher.data?.schedule; + // Treat stale data (previous schedule still in fetcher cache after the + // user clicked a different row) as loading — otherwise we briefly flash + // the previous schedule's content while the new fetch is in flight. + const isStaleSchedule = !!schedule && !!openScheduleId && schedule.friendlyId !== openScheduleId; + // Only show the loading spinner when we actually lack good data — + // background reloads (e.g. after enable/disable) keep the inspector + // visible with its current values until the fresh data arrives. + const isDetailLoading = + isStaleSchedule || (!!openScheduleId && detailFetcher.data === undefined); + // Distinct from loading: the loader has resolved and the schedule is + // genuinely gone (returned `null`, e.g. deleted externally). + const isScheduleMissing = + !!openScheduleId && !isDetailLoading && detailFetcher.data?.schedule === null; + const editData = editFetcher.data; + // Mirror the detail-fetcher staleness check so the edit form doesn't + // briefly flash a previously-edited schedule's data on the first render + // after switching schedules. + const isStaleEditData = + !!editData?.schedule && !!openScheduleId && editData.schedule.friendlyId !== openScheduleId; + const isEditLoading = + mode === "edit" && (editFetcher.state === "loading" || !editData || isStaleEditData); return ( !open && onClose()}> @@ -551,10 +675,34 @@ function ScheduleSheet({ className="w-[480px] max-w-none border-l border-grid-dimmed bg-background-bright p-0 sm:max-w-none" onOpenAutoFocus={(e) => e.preventDefault()} > - {isLoading || !schedule ? ( + {mode === "edit" ? ( + isEditLoading || !editData ? ( + + ) : ( + setMode("inspect")} + submitFetcher={updateFetcher} + /> + ) + ) : isDetailLoading ? ( + ) : isScheduleMissing ? ( + + ) : schedule ? ( + setMode("edit")} + activeToggleFetcher={activeToggleFetcher} + deleteFetcher={deleteFetcher} + /> ) : ( - + )} @@ -572,9 +720,19 @@ function ScheduledTaskDetailSidebar({ LoaderData, "scheduleList" >) { + const sortedSchedules = useMemo(() => { + if (!scheduleList) return []; + // DECLARATIVE first; createdAt-desc within each type (stable sort). + return [...scheduleList.schedules].sort((a, b) => { + if (a.type === b.type) return 0; + return a.type === "DECLARATIVE" ? -1 : 1; + }); + }, [scheduleList?.schedules]); + const firstSchedule = sortedSchedules[0]; + const [activeTab, setActiveTab] = useState<"overview" | "schedules">("overview"); return ( -
-
+
+
{task.slug} @@ -590,48 +748,136 @@ function ScheduledTaskDetailSidebar({ Test schedule
-
- - - Identifier - - - - - - File path - - - - - - Type - - Scheduled task - - - - Created - - - - - -
- Schedules -
- {scheduleList ? ( +
+ + setActiveTab("overview")} + shortcut={{ key: "o" }} + > + Overview + + setActiveTab("schedules")} + shortcut={{ key: "s" }} + > + Schedules + + + {activeTab === "schedules" && scheduleList && scheduleList.totalPages > 1 ? ( +
+ +
+ ) : null} +
+ {activeTab === "overview" ? ( +
+ + + Identifier + + + + + + File path + + + + + + Schedule ID + + {firstSchedule ? ( + + ) : ( + + )} + + + + CRON + + {firstSchedule ? ( +
+ {firstSchedule.cron} + {firstSchedule.cronDescription} +
+ ) : ( + + )} +
+
+ + Created + + + + + + Next run + + {firstSchedule ? ( + + ) : ( + + )} + + + + Last run + + {firstSchedule?.lastRun ? ( + + ) : ( + Never + )} + + + + Status + + {firstSchedule ? ( + + ) : ( + + )} + + +
+ {scheduleList && sortedSchedules.length === 0 ? ( +
+ +
+ ) : null} +
+ ) : ( +
+ {scheduleList ? ( + sortedSchedules.length === 0 ? ( +
+ +
+ ) : ( - ) : ( - - )} -
+ ) + ) : ( + + )}
-
+ )}
); } @@ -652,14 +898,16 @@ function SchedulesMiniTable({ schedules, variant, onSelectSchedule, + showTopBorder = true, }: { schedules: ScheduleRow[]; variant?: TableVariant; onSelectSchedule: (friendlyId: string) => void; + showTopBorder?: boolean; }) { if (schedules.length === 0) { return ( - +
@@ -672,7 +920,7 @@ function SchedulesMiniTable({ } return ( -
+
Schedule ID @@ -839,6 +1087,38 @@ function ActivityChartSkeleton() { ); } +function NoSchedulesAttachedPanel() { + return ( + + Read the docs + + } + > + + Scheduled tasks only run automatically when a schedule is attached. There are two types: + + + Declarative — defined directly on your{" "} + schedules.task and synced when you run dev or deploy. + + + Imperative — created dynamically from + the dashboard or via the SDK with schedules.create(). + + + ); +} + function TableLoading() { return (
@@ -846,3 +1126,16 @@ function TableLoading() {
); } + +function ScheduleMissingPanel({ onClose }: { onClose: () => void }) { + return ( +
+ + This schedule no longer exists. + + +
+ ); +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx index 37686eec81..1f7a2add41 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx @@ -1,7 +1,13 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; -import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react"; +import { + type FetcherWithComponents, + Form, + useActionData, + useLocation, + useNavigation, +} from "@remix-run/react"; import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { useVirtualizer } from "@tanstack/react-virtual"; import { parseExpression } from "cron-parser"; @@ -52,7 +58,6 @@ import { AIGeneratedCronField } from "../resources.orgs.$organizationSlug.projec import { TimezoneList } from "~/components/scheduled/timezones"; import { logger } from "~/services/logger.server"; import { Spinner } from "~/components/primitives/Spinner"; -import { cond } from "effect/STM"; import { useEnvironment } from "~/hooks/useEnvironment"; const cronFormat = `* * * * * @@ -75,6 +80,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return json(submission); } + // `_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({ @@ -98,15 +106,25 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { 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, - submission.value?.friendlyId === result.id ? "Schedule updated" : "Schedule created" + 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, @@ -132,20 +150,30 @@ export function UpsertScheduleForm({ possibleTimezones, showGenerateField, defaultTaskIdentifier, + onCancel, + submitFetcher, }: EditableScheduleElements & { showGenerateField: boolean; - /** - * Pre-fills the Task select when creating a new schedule (no `schedule` - * passed). Ignored when editing. - */ + /** 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 lastSubmission = useActionData(); + const actionData = useActionData(); + // Only feed conform-shaped data (`intent`) to `useForm` — `{ ok, message }` + // envelopes lack `payload` and crash conform. + const fetcherSubmission = + submitFetcher?.data && typeof submitFetcher.data === "object" && "intent" 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 = navigation.state !== "idle"; + const isLoading = submitFetcher ? submitFetcher.state !== "idle" : navigation.state !== "idle"; const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -153,7 +181,9 @@ export function UpsertScheduleForm({ const [form, { taskIdentifier, cron, timezone, externalId, environments, deduplicationKey }] = useForm({ - id: "create-schedule", + // 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 lastSubmission: lastSubmission as any, shouldRevalidate: "onSubmit", @@ -197,13 +227,14 @@ export function UpsertScheduleForm({ } const mode = schedule ? "edit" : "new"; + const FormComponent = submitFetcher?.Form ?? Form; return ( -
@@ -216,36 +247,41 @@ export function UpsertScheduleForm({
+ {submitFetcher ? : null} {schedule && }
- {!schedule && defaultTaskIdentifier ? ( - - ) : ( - - - - {taskIdentifier.error} - - )} + {(() => { + // 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.error} + + ); + })()} {showGenerateField && }
-
+
- - Cancel - + {onCancel ? ( + + ) : ( + + Cancel + + )}
- + ); }