Skip to content

Commit afe6dd9

Browse files
authored
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
1 parent 17482c0 commit afe6dd9

6 files changed

Lines changed: 563 additions & 161 deletions

File tree

  • apps/webapp/app
    • components
    • routes
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam
      • resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new

apps/webapp/app/components/BlankStatePanels.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ export function SessionsNone() {
198198
panelClassName="max-w-full"
199199
accessory={
200200
<LinkButton
201-
to={docsPath("/ai-chat/sessions")}
201+
to={docsPath("ai-chat/sessions")}
202202
variant="docs/small"
203203
LeadingIcon={BookOpenIcon}
204204
>

apps/webapp/app/components/schedules/ScheduleInspector.tsx

Lines changed: 73 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
TrashIcon,
77
} from "@heroicons/react/20/solid";
88
import { DialogDescription } from "@radix-ui/react-dialog";
9-
import { Form, useLocation } from "@remix-run/react";
9+
import { type FetcherWithComponents, Form, useLocation } from "@remix-run/react";
1010
import { type ReactNode } from "react";
1111
import { InlineCode } from "~/components/code/InlineCode";
1212
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
@@ -76,9 +76,22 @@ type Props = {
7676
* is rendered somewhere else (e.g. in a sheet on a different page).
7777
*/
7878
actionPath?: string;
79+
/** When set, Edit calls back instead of navigating to the standalone edit page. */
80+
onEdit?: () => void;
81+
/** Submits enable/disable via this fetcher with `_format=json` so the host stays put. */
82+
activeToggleFetcher?: FetcherWithComponents<unknown>;
83+
/** Submits delete via this fetcher with `_format=json` so the host stays put. */
84+
deleteFetcher?: FetcherWithComponents<unknown>;
7985
};
8086

81-
export function ScheduleInspector({ schedule, headerActions, actionPath }: Props) {
87+
export function ScheduleInspector({
88+
schedule,
89+
headerActions,
90+
actionPath,
91+
onEdit,
92+
activeToggleFetcher,
93+
deleteFetcher,
94+
}: Props) {
8295
const location = useLocation();
8396
const organization = useOrganization();
8497
const project = useProject();
@@ -91,7 +104,7 @@ export function ScheduleInspector({ schedule, headerActions, actionPath }: Props
91104
<div
92105
className={cn(
93106
"grid h-full max-h-full overflow-hidden bg-background-bright",
94-
isImperative ? "grid-rows-[2.5rem_1fr_3.25rem]" : "grid-rows-[2.5rem_1fr]"
107+
isImperative ? "grid-rows-[2.5rem_1fr_auto]" : "grid-rows-[2.5rem_1fr]"
95108
)}
96109
>
97110
<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
244257
</div>
245258
</div>
246259
{isImperative && (
247-
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2">
260+
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2 py-2">
248261
<div className="flex items-center gap-2">
249-
<Form method="post" action={actionPath}>
250-
<Button
251-
type="submit"
252-
variant="tertiary/medium"
253-
LeadingIcon={schedule.active ? BoltSlashIcon : BoltIcon}
254-
leadingIconClassName={schedule.active ? "text-dimmed" : "text-success"}
255-
name="action"
256-
value={schedule.active ? "disable" : "enable"}
257-
>
258-
{schedule.active ? "Disable" : "Enable"}
259-
</Button>
260-
</Form>
262+
{(() => {
263+
const ToggleForm = activeToggleFetcher?.Form ?? Form;
264+
const isSubmitting = activeToggleFetcher?.state === "submitting";
265+
return (
266+
<ToggleForm method="post" action={actionPath}>
267+
{activeToggleFetcher ? <input type="hidden" name="_format" value="json" /> : null}
268+
<Button
269+
type="submit"
270+
variant="secondary/small"
271+
LeadingIcon={schedule.active ? BoltSlashIcon : BoltIcon}
272+
leadingIconClassName={schedule.active ? "text-dimmed" : "text-success"}
273+
name="action"
274+
value={schedule.active ? "disable" : "enable"}
275+
disabled={isSubmitting}
276+
>
277+
{schedule.active ? "Disable" : "Enable"}
278+
</Button>
279+
</ToggleForm>
280+
);
281+
})()}
261282
<Dialog>
262283
<DialogTrigger asChild>
263284
<Button
264285
type="submit"
265-
variant="danger/medium"
286+
variant="danger/small"
266287
LeadingIcon={TrashIcon}
267288
name="action"
268289
value="delete"
269290
>
270-
Delete
291+
Delete
271292
</Button>
272293
</DialogTrigger>
273294
<DialogContent className="sm:max-w-sm">
@@ -276,31 +297,45 @@ export function ScheduleInspector({ schedule, headerActions, actionPath }: Props
276297
Are you sure you want to delete this schedule? This can't be reversed.
277298
</DialogDescription>
278299
<DialogFooter className="sm:justify-end">
279-
<Form method="post" action={actionPath}>
280-
<Button
281-
type="submit"
282-
variant="danger/medium"
283-
LeadingIcon={TrashIcon}
284-
name="action"
285-
value="delete"
286-
>
287-
Delete
288-
</Button>
289-
</Form>
300+
{(() => {
301+
const DeleteForm = deleteFetcher?.Form ?? Form;
302+
const isSubmitting = deleteFetcher?.state === "submitting";
303+
return (
304+
<DeleteForm method="post" action={actionPath}>
305+
{deleteFetcher ? <input type="hidden" name="_format" value="json" /> : null}
306+
<Button
307+
type="submit"
308+
variant="danger/medium"
309+
LeadingIcon={TrashIcon}
310+
name="action"
311+
value="delete"
312+
disabled={isSubmitting}
313+
>
314+
Delete
315+
</Button>
316+
</DeleteForm>
317+
);
318+
})()}
290319
</DialogFooter>
291320
</DialogContent>
292321
</Dialog>
293322
</div>
294323
<div className="flex items-center gap-4">
295-
<LinkButton
296-
variant="tertiary/medium"
297-
to={`${v3EditSchedulePath(organization, project, environment, schedule)}${
298-
location.search
299-
}`}
300-
LeadingIcon={PencilSquareIcon}
301-
>
302-
Edit schedule
303-
</LinkButton>
324+
{onEdit ? (
325+
<Button variant="secondary/small" LeadingIcon={PencilSquareIcon} onClick={onEdit}>
326+
Edit schedule…
327+
</Button>
328+
) : (
329+
<LinkButton
330+
variant="secondary/small"
331+
to={`${v3EditSchedulePath(organization, project, environment, schedule)}${
332+
location.search
333+
}`}
334+
LeadingIcon={PencilSquareIcon}
335+
>
336+
Edit schedule…
337+
</LinkButton>
338+
)}
304339
</div>
305340
</div>
306341
)}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route.tsx

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
1616
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
1717
import { requireUserId } from "~/services/session.server";
1818
import { v3EnvironmentPath, v3ScheduleParams, v3SchedulePath } from "~/utils/pathBuilder";
19-
import { throwNotFound } from "~/utils/httpErrors";
2019
import { DeleteTaskScheduleService } from "~/v3/services/deleteTaskSchedule.server";
2120
import { SetActiveOnTaskScheduleService } from "~/v3/services/setActiveOnTaskSchedule.server";
2221

@@ -45,11 +44,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
4544
environmentId: environment.id,
4645
});
4746

48-
if (!result) {
49-
throwNotFound("Schedule not found");
50-
}
51-
52-
return typedjson({ schedule: result.schedule });
47+
// Return null (not a 404 throw) so fetcher-driven hosts (e.g. the sheet
48+
// running this loader after a delete-in-flight) don't surface a
49+
// page-level error boundary. The standalone Page below renders a
50+
// not-found message when `schedule` is null.
51+
return typedjson({ schedule: result?.schedule ?? null });
5352
};
5453

5554
const schema = z.discriminatedUnion("action", [
@@ -76,13 +75,20 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
7675
return json(submission);
7776
}
7877

78+
// `_format=json` → return JSON instead of redirecting; caller stays put.
79+
const wantsJson = formData.get("_format") === "json";
80+
7981
const project = await prisma.project.findFirst({
8082
where: {
8183
slug: projectParam,
8284
},
8385
});
8486

8587
if (!project) {
88+
const message = `No project found with slug ${projectParam}`;
89+
if (wantsJson) {
90+
return json({ ok: false as const, message }, { status: 404 });
91+
}
8692
return redirectWithErrorMessage(
8793
v3SchedulePath(
8894
{ slug: organizationSlug },
@@ -91,7 +97,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
9197
{ friendlyId: scheduleParam }
9298
),
9399
request,
94-
`No project found with slug ${projectParam}`
100+
message
95101
);
96102
}
97103

@@ -104,12 +110,21 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
104110
userId,
105111
friendlyId: scheduleParam,
106112
});
113+
if (wantsJson) {
114+
return json({ ok: true as const, message: `${scheduleParam} deleted` });
115+
}
107116
return redirectWithSuccessMessage(
108117
v3EnvironmentPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
109118
request,
110119
`${scheduleParam} deleted`
111120
);
112121
} catch (e) {
122+
const message = `${scheduleParam} could not be deleted: ${
123+
e instanceof Error ? e.message : JSON.stringify(e)
124+
}`;
125+
if (wantsJson) {
126+
return json({ ok: false as const, message }, { status: 500 });
127+
}
113128
return redirectWithErrorMessage(
114129
v3SchedulePath(
115130
{ slug: organizationSlug },
@@ -118,9 +133,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
118133
{ friendlyId: scheduleParam }
119134
),
120135
request,
121-
`${scheduleParam} could not be deleted: ${
122-
e instanceof Error ? e.message : JSON.stringify(e)
123-
}`
136+
message
124137
);
125138
}
126139
}
@@ -135,6 +148,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
135148
friendlyId: scheduleParam,
136149
active,
137150
});
151+
if (wantsJson) {
152+
return json({ ok: true as const, active });
153+
}
138154
return redirectWithSuccessMessage(
139155
v3SchedulePath(
140156
{ slug: organizationSlug },
@@ -146,6 +162,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
146162
`${scheduleParam} ${active ? "enabled" : "disabled"}`
147163
);
148164
} catch (e) {
165+
const message = e instanceof Error ? e.message : JSON.stringify(e);
166+
if (wantsJson) {
167+
return json({ ok: false as const, message }, { status: 500 });
168+
}
149169
return redirectWithErrorMessage(
150170
v3SchedulePath(
151171
{ slug: organizationSlug },
@@ -154,9 +174,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
154174
{ friendlyId: scheduleParam }
155175
),
156176
request,
157-
`${scheduleParam} could not be ${active ? "enabled" : "disabled"}: ${
158-
e instanceof Error ? e.message : JSON.stringify(e)
159-
}`
177+
`${scheduleParam} could not be ${active ? "enabled" : "disabled"}: ${message}`
160178
);
161179
}
162180
}
@@ -170,6 +188,20 @@ export default function Page() {
170188
const project = useProject();
171189
const environment = useEnvironment();
172190

191+
if (!schedule) {
192+
return (
193+
<div className="flex h-full flex-col items-center justify-center gap-3 bg-background-bright p-6">
194+
<p className="text-sm text-text-bright">Schedule not found.</p>
195+
<LinkButton
196+
to={`${v3EnvironmentPath(organization, project, environment)}${location.search}`}
197+
variant="secondary/small"
198+
>
199+
Back to tasks
200+
</LinkButton>
201+
</div>
202+
);
203+
}
204+
173205
return (
174206
<ScheduleInspector
175207
schedule={schedule}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export default function Page() {
8484
<LinkButton
8585
variant={"docs/small"}
8686
LeadingIcon={BookOpenIcon}
87-
to={docsPath("/ai-chat/sessions")}
87+
to={docsPath("ai-chat/sessions")}
8888
>
8989
Sessions docs
9090
</LinkButton>

0 commit comments

Comments
 (0)