Feat(webapp): schedules fixes and UI improvement (#3965)
## Summary Reworks the scheduled task page right-hand sidebar. - Adds **Overview** / **Schedules** tabs. The Schedules tab is a paginated table of all schedules attached to the task, declarative first. - Surfaces schedule fields (ID, CRON + human-readable description, next/last run, status) directly in the Overview property table. - Sidebar can be dragged much wider (up to 80% of the viewport). - "No schedules attached" panel explains declarative vs imperative and links to docs. - Schedule **create / edit / enable / disable / delete** all happen inside the existing Sheet — no more navigating to the standalone schedule page. Toasts confirm each action. ## Test plan - Open a scheduled task page and verify the new tabs - Create, edit, enable/disable, and delete a schedule — confirm you stay on the page and see a toast each time - Visit a task with no schedules attached and confirm the info panel renders - Drag the sidebar wider; confirm pagination shows when there are >25 schedules
This commit is contained in:
@@ -198,7 +198,7 @@ export function SessionsNone() {
|
||||
panelClassName="max-w-full"
|
||||
accessory={
|
||||
<LinkButton
|
||||
to={docsPath("/ai-chat/sessions")}
|
||||
to={docsPath("ai-chat/sessions")}
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
>
|
||||
|
||||
@@ -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<unknown>;
|
||||
/** Submits delete via this fetcher with `_format=json` so the host stays put. */
|
||||
deleteFetcher?: FetcherWithComponents<unknown>;
|
||||
};
|
||||
|
||||
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
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-full max-h-full overflow-hidden bg-background-bright",
|
||||
isImperative ? "grid-rows-[2.5rem_1fr_3.25rem]" : "grid-rows-[2.5rem_1fr]"
|
||||
isImperative ? "grid-rows-[2.5rem_1fr_auto]" : "grid-rows-[2.5rem_1fr]"
|
||||
)}
|
||||
>
|
||||
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
|
||||
@@ -244,30 +257,38 @@ export function ScheduleInspector({ schedule, headerActions, actionPath }: Props
|
||||
</div>
|
||||
</div>
|
||||
{isImperative && (
|
||||
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2">
|
||||
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Form method="post" action={actionPath}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="tertiary/medium"
|
||||
LeadingIcon={schedule.active ? BoltSlashIcon : BoltIcon}
|
||||
leadingIconClassName={schedule.active ? "text-dimmed" : "text-success"}
|
||||
name="action"
|
||||
value={schedule.active ? "disable" : "enable"}
|
||||
>
|
||||
{schedule.active ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
</Form>
|
||||
{(() => {
|
||||
const ToggleForm = activeToggleFetcher?.Form ?? Form;
|
||||
const isSubmitting = activeToggleFetcher?.state === "submitting";
|
||||
return (
|
||||
<ToggleForm method="post" action={actionPath}>
|
||||
{activeToggleFetcher ? <input type="hidden" name="_format" value="json" /> : null}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary/small"
|
||||
LeadingIcon={schedule.active ? BoltSlashIcon : BoltIcon}
|
||||
leadingIconClassName={schedule.active ? "text-dimmed" : "text-success"}
|
||||
name="action"
|
||||
value={schedule.active ? "disable" : "enable"}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{schedule.active ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
</ToggleForm>
|
||||
);
|
||||
})()}
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger/medium"
|
||||
variant="danger/small"
|
||||
LeadingIcon={TrashIcon}
|
||||
name="action"
|
||||
value="delete"
|
||||
>
|
||||
Delete
|
||||
Delete…
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
@@ -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.
|
||||
</DialogDescription>
|
||||
<DialogFooter className="sm:justify-end">
|
||||
<Form method="post" action={actionPath}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger/medium"
|
||||
LeadingIcon={TrashIcon}
|
||||
name="action"
|
||||
value="delete"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Form>
|
||||
{(() => {
|
||||
const DeleteForm = deleteFetcher?.Form ?? Form;
|
||||
const isSubmitting = deleteFetcher?.state === "submitting";
|
||||
return (
|
||||
<DeleteForm method="post" action={actionPath}>
|
||||
{deleteFetcher ? <input type="hidden" name="_format" value="json" /> : null}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger/medium"
|
||||
LeadingIcon={TrashIcon}
|
||||
name="action"
|
||||
value="delete"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DeleteForm>
|
||||
);
|
||||
})()}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<LinkButton
|
||||
variant="tertiary/medium"
|
||||
to={`${v3EditSchedulePath(organization, project, environment, schedule)}${
|
||||
location.search
|
||||
}`}
|
||||
LeadingIcon={PencilSquareIcon}
|
||||
>
|
||||
Edit schedule
|
||||
</LinkButton>
|
||||
{onEdit ? (
|
||||
<Button variant="secondary/small" LeadingIcon={PencilSquareIcon} onClick={onEdit}>
|
||||
Edit schedule…
|
||||
</Button>
|
||||
) : (
|
||||
<LinkButton
|
||||
variant="secondary/small"
|
||||
to={`${v3EditSchedulePath(organization, project, environment, schedule)}${
|
||||
location.search
|
||||
}`}
|
||||
LeadingIcon={PencilSquareIcon}
|
||||
>
|
||||
Edit schedule…
|
||||
</LinkButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+45
-13
@@ -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 (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 bg-background-bright p-6">
|
||||
<p className="text-sm text-text-bright">Schedule not found.</p>
|
||||
<LinkButton
|
||||
to={`${v3EnvironmentPath(organization, project, environment)}${location.search}`}
|
||||
variant="secondary/small"
|
||||
>
|
||||
Back to tasks
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScheduleInspector
|
||||
schedule={schedule}
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ export default function Page() {
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("/ai-chat/sessions")}
|
||||
to={docsPath("ai-chat/sessions")}
|
||||
>
|
||||
Sessions docs
|
||||
</LinkButton>
|
||||
|
||||
+351
-58
@@ -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() {
|
||||
<div className="grid h-full grid-rows-[auto_1fr_auto] overflow-hidden">
|
||||
{/* Top bar — title on the left; actions + TimeFilter + pagination on the right.
|
||||
h-10 matches the right-hand sidebar header height. */}
|
||||
<div className="flex h-10 items-center border-b border-grid-dimmed bg-background-bright pl-3 pr-2">
|
||||
<div className="flex min-h-10 items-center gap-2 border-b border-grid-dimmed bg-background-bright py-2 pl-3 pr-2">
|
||||
<Header2>Runs</Header2>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-1.5">
|
||||
<CreateScheduleButton
|
||||
isAtLimit={isAtLimit}
|
||||
limits={limits}
|
||||
@@ -355,7 +361,7 @@ export default function Page() {
|
||||
id="scheduled-task-detail"
|
||||
min="280px"
|
||||
default="380px"
|
||||
max="500px"
|
||||
max="80%"
|
||||
isStaticAtRest
|
||||
>
|
||||
<ScheduledTaskDetailSidebar
|
||||
@@ -486,12 +492,34 @@ function CreateScheduleSheet({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const fetcher = useTypedFetcher<typeof scheduleNewLoader>();
|
||||
// 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<unknown>(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}
|
||||
/>
|
||||
)}
|
||||
</SheetContent>
|
||||
@@ -532,17 +562,111 @@ function ScheduleSheet({
|
||||
environment: ReturnType<typeof useEnvironment>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const fetcher = useTypedFetcher<typeof scheduleDetailLoader>();
|
||||
const detailFetcher = useTypedFetcher<typeof scheduleDetailLoader>();
|
||||
const editFetcher = useTypedFetcher<typeof scheduleEditLoader>();
|
||||
// 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<unknown>(null);
|
||||
const handledUpdateRef = useRef<unknown>(null);
|
||||
const handledDeleteRef = useRef<unknown>(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 (
|
||||
<Sheet open={!!openScheduleId} onOpenChange={(open) => !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 ? (
|
||||
<TableLoading />
|
||||
) : (
|
||||
<UpsertScheduleForm
|
||||
schedule={editData.schedule}
|
||||
possibleTasks={editData.possibleTasks}
|
||||
possibleEnvironments={editData.possibleEnvironments}
|
||||
possibleTimezones={editData.possibleTimezones}
|
||||
showGenerateField={editData.showGenerateField}
|
||||
onCancel={() => setMode("inspect")}
|
||||
submitFetcher={updateFetcher}
|
||||
/>
|
||||
)
|
||||
) : isDetailLoading ? (
|
||||
<TableLoading />
|
||||
) : isScheduleMissing ? (
|
||||
<ScheduleMissingPanel onClose={onClose} />
|
||||
) : schedule ? (
|
||||
<ScheduleInspector
|
||||
schedule={schedule}
|
||||
actionPath={detailPath}
|
||||
onEdit={() => setMode("edit")}
|
||||
activeToggleFetcher={activeToggleFetcher}
|
||||
deleteFetcher={deleteFetcher}
|
||||
/>
|
||||
) : (
|
||||
<ScheduleInspector schedule={schedule} actionPath={detailPath} />
|
||||
<TableLoading />
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -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 (
|
||||
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden border-b border-grid-dimmed px-3 py-2">
|
||||
<div className="grid h-full grid-rows-[auto_auto_minmax(0,1fr)] overflow-hidden bg-background-bright">
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden py-2 pl-3 pr-1.5">
|
||||
<Header2 className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
|
||||
<ClockIcon className="size-4.5 shrink-0 text-schedules" />
|
||||
<span className="truncate">{task.slug}</span>
|
||||
@@ -590,48 +748,136 @@ function ScheduledTaskDetailSidebar({
|
||||
Test schedule
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Identifier</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={task.slug} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>File path</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={task.filePath} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Type</Property.Label>
|
||||
<Property.Value>
|
||||
<Paragraph variant="small">Scheduled task</Paragraph>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Created</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={task.createdAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<Header3>Schedules</Header3>
|
||||
<div className="-mx-3 overflow-hidden border-y border-grid-dimmed">
|
||||
{scheduleList ? (
|
||||
<div className="flex h-8 items-end justify-between gap-2 border-b border-grid-bright pl-3 pr-1.5">
|
||||
<TabContainer className="!border-b-0">
|
||||
<TabButton
|
||||
isActive={activeTab === "overview"}
|
||||
layoutId="scheduled-task-detail-tabs"
|
||||
onClick={() => setActiveTab("overview")}
|
||||
shortcut={{ key: "o" }}
|
||||
>
|
||||
Overview
|
||||
</TabButton>
|
||||
<TabButton
|
||||
isActive={activeTab === "schedules"}
|
||||
layoutId="scheduled-task-detail-tabs"
|
||||
onClick={() => setActiveTab("schedules")}
|
||||
shortcut={{ key: "s" }}
|
||||
>
|
||||
Schedules
|
||||
</TabButton>
|
||||
</TabContainer>
|
||||
{activeTab === "schedules" && scheduleList && scheduleList.totalPages > 1 ? (
|
||||
<div className="pb-1.5">
|
||||
<PaginationControls
|
||||
currentPage={scheduleList.currentPage}
|
||||
totalPages={scheduleList.totalPages}
|
||||
showPageNumbers={false}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{activeTab === "overview" ? (
|
||||
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Identifier</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={task.slug} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>File path</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={task.filePath} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Schedule ID</Property.Label>
|
||||
<Property.Value>
|
||||
{firstSchedule ? (
|
||||
<CopyableText value={firstSchedule.friendlyId} />
|
||||
) : (
|
||||
<span className="text-text-dimmed">–</span>
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>CRON</Property.Label>
|
||||
<Property.Value>
|
||||
{firstSchedule ? (
|
||||
<div className="space-y-2">
|
||||
<InlineCode variant="extra-small">{firstSchedule.cron}</InlineCode>
|
||||
<Paragraph variant="small">{firstSchedule.cronDescription}</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-text-dimmed">–</span>
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Created</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={task.createdAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Next run</Property.Label>
|
||||
<Property.Value>
|
||||
{firstSchedule ? (
|
||||
<RelativeDateTime date={firstSchedule.nextRun} />
|
||||
) : (
|
||||
<span className="text-text-dimmed">–</span>
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Last run</Property.Label>
|
||||
<Property.Value>
|
||||
{firstSchedule?.lastRun ? (
|
||||
<RelativeDateTime date={firstSchedule.lastRun} />
|
||||
) : (
|
||||
<span className="text-text-dimmed">Never</span>
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Status</Property.Label>
|
||||
<Property.Value>
|
||||
{firstSchedule ? (
|
||||
<EnabledStatus enabled={firstSchedule.active} />
|
||||
) : (
|
||||
<span className="text-text-dimmed">–</span>
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
{scheduleList && sortedSchedules.length === 0 ? (
|
||||
<div className="mt-4">
|
||||
<NoSchedulesAttachedPanel />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
{scheduleList ? (
|
||||
sortedSchedules.length === 0 ? (
|
||||
<div className="p-3">
|
||||
<NoSchedulesAttachedPanel />
|
||||
</div>
|
||||
) : (
|
||||
<SchedulesMiniTable
|
||||
schedules={scheduleList.schedules}
|
||||
schedules={sortedSchedules}
|
||||
variant="bright"
|
||||
onSelectSchedule={onSelectSchedule}
|
||||
showTopBorder={false}
|
||||
/>
|
||||
) : (
|
||||
<TableLoading />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<TableLoading />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Table variant={variant}>
|
||||
<Table variant={variant} showTopBorder={showTopBorder}>
|
||||
<TableBody>
|
||||
<TableBlankRow colSpan={6}>
|
||||
<Paragraph variant="small" className="flex items-center justify-center">
|
||||
@@ -672,7 +920,7 @@ function SchedulesMiniTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<Table variant={variant}>
|
||||
<Table variant={variant} showTopBorder={showTopBorder}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Schedule ID</TableHeaderCell>
|
||||
@@ -839,6 +1087,38 @@ function ActivityChartSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function NoSchedulesAttachedPanel() {
|
||||
return (
|
||||
<InfoPanel
|
||||
title="No schedules attached"
|
||||
icon={ClockIcon}
|
||||
iconClassName="text-schedules"
|
||||
panelClassName="max-w-full"
|
||||
accessory={
|
||||
<LinkButton
|
||||
to={docsPath("v3/tasks-scheduled")}
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
>
|
||||
Read the docs
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
Scheduled tasks only run automatically when a schedule is attached. There are two types:
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="small">
|
||||
<span className="font-medium text-text-bright">Declarative</span> — defined directly on your{" "}
|
||||
<InlineCode>schedules.task</InlineCode> and synced when you run dev or deploy.
|
||||
</Paragraph>
|
||||
<Paragraph variant="small">
|
||||
<span className="font-medium text-text-bright">Imperative</span> — created dynamically from
|
||||
the dashboard or via the SDK with <InlineCode>schedules.create()</InlineCode>.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function TableLoading() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
@@ -846,3 +1126,16 @@ function TableLoading() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleMissingPanel({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 bg-background-bright p-6 text-center">
|
||||
<Paragraph variant="small" className="text-text-bright">
|
||||
This schedule no longer exists.
|
||||
</Paragraph>
|
||||
<Button variant="secondary/small" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+92
-50
@@ -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<unknown>;
|
||||
}) {
|
||||
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<string>(schedule?.timezone ?? "UTC");
|
||||
const isUtc = selectedTimezone === "UTC";
|
||||
const [cronPattern, setCronPattern] = useState<string>(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 (
|
||||
<Form
|
||||
<FormComponent
|
||||
method="post"
|
||||
action={`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/schedules/new`}
|
||||
{...form.props}
|
||||
className="grid h-full max-h-full grid-rows-[2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright"
|
||||
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">
|
||||
@@ -216,36 +247,41 @@ export function UpsertScheduleForm({
|
||||
</div>
|
||||
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className="p-3">
|
||||
{submitFetcher ? <input type="hidden" name="_format" value="json" /> : null}
|
||||
{schedule && <input type="hidden" name="friendlyId" value={schedule.friendlyId} />}
|
||||
<Fieldset>
|
||||
{!schedule && defaultTaskIdentifier ? (
|
||||
<input type="hidden" name={taskIdentifier.name} value={defaultTaskIdentifier} />
|
||||
) : (
|
||||
<InputGroup>
|
||||
<Label htmlFor={taskIdentifier.id}>Task</Label>
|
||||
<Select
|
||||
{...conform.select(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.error}</FormError>
|
||||
</InputGroup>
|
||||
)}
|
||||
{(() => {
|
||||
// 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
|
||||
{...conform.select(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.error}</FormError>
|
||||
</InputGroup>
|
||||
);
|
||||
})()}
|
||||
{showGenerateField && <AIGeneratedCronField onSuccess={setCronPattern} />}
|
||||
<InputGroup>
|
||||
<Label
|
||||
@@ -415,28 +451,34 @@ export function UpsertScheduleForm({
|
||||
</Fieldset>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2">
|
||||
<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">
|
||||
<LinkButton
|
||||
to={`${v3EnvironmentPath(organization, project, environment)}${location.search}`}
|
||||
variant="secondary/medium"
|
||||
>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
{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/medium"
|
||||
variant="primary/small"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
shortcut={{ key: "enter", modifiers: ["mod"] }}
|
||||
shortcut={{ key: "enter", modifiers: ["mod"], enabledOnInputElements: true }}
|
||||
LeadingIcon={isLoading ? Spinner : undefined}
|
||||
>
|
||||
{buttonText(mode, isLoading)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</FormComponent>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user