Skip to content

Commit 3d33e21

Browse files
committed
Separates schedules table into tabs + fixes to the imperative create/delete flow
1 parent 89622b0 commit 3d33e21

4 files changed

Lines changed: 512 additions & 156 deletions

File tree

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

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: 40 additions & 12 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,6 +75,9 @@ 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,
@@ -104,12 +106,21 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
104106
userId,
105107
friendlyId: scheduleParam,
106108
});
109+
if (wantsJson) {
110+
return json({ ok: true as const, message: `${scheduleParam} deleted` });
111+
}
107112
return redirectWithSuccessMessage(
108113
v3EnvironmentPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
109114
request,
110115
`${scheduleParam} deleted`
111116
);
112117
} catch (e) {
118+
const message = `${scheduleParam} could not be deleted: ${
119+
e instanceof Error ? e.message : JSON.stringify(e)
120+
}`;
121+
if (wantsJson) {
122+
return json({ ok: false as const, message }, { status: 500 });
123+
}
113124
return redirectWithErrorMessage(
114125
v3SchedulePath(
115126
{ slug: organizationSlug },
@@ -118,9 +129,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
118129
{ friendlyId: scheduleParam }
119130
),
120131
request,
121-
`${scheduleParam} could not be deleted: ${
122-
e instanceof Error ? e.message : JSON.stringify(e)
123-
}`
132+
message
124133
);
125134
}
126135
}
@@ -135,6 +144,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
135144
friendlyId: scheduleParam,
136145
active,
137146
});
147+
if (wantsJson) {
148+
return json({ ok: true as const, active });
149+
}
138150
return redirectWithSuccessMessage(
139151
v3SchedulePath(
140152
{ slug: organizationSlug },
@@ -146,6 +158,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
146158
`${scheduleParam} ${active ? "enabled" : "disabled"}`
147159
);
148160
} catch (e) {
161+
const message = e instanceof Error ? e.message : JSON.stringify(e);
162+
if (wantsJson) {
163+
return json({ ok: false as const, message }, { status: 500 });
164+
}
149165
return redirectWithErrorMessage(
150166
v3SchedulePath(
151167
{ slug: organizationSlug },
@@ -154,9 +170,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
154170
{ friendlyId: scheduleParam }
155171
),
156172
request,
157-
`${scheduleParam} could not be ${active ? "enabled" : "disabled"}: ${
158-
e instanceof Error ? e.message : JSON.stringify(e)
159-
}`
173+
`${scheduleParam} could not be ${active ? "enabled" : "disabled"}: ${message}`
160174
);
161175
}
162176
}
@@ -170,6 +184,20 @@ export default function Page() {
170184
const project = useProject();
171185
const environment = useEnvironment();
172186

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

0 commit comments

Comments
 (0)