diff --git a/ui-v2/src/api/deployments/index.ts b/ui-v2/src/api/deployments/index.ts index f4ca793ec907..56bba8c79830 100644 --- a/ui-v2/src/api/deployments/index.ts +++ b/ui-v2/src/api/deployments/index.ts @@ -11,6 +11,7 @@ export type Deployment = components["schemas"]["DeploymentResponse"]; export type DeploymentWithFlow = Deployment & { flow?: components["schemas"]["Flow"]; }; +export type DeploymentSchedule = components["schemas"]["DeploymentSchedule"]; export type DeploymentsFilter = components["schemas"]["Body_read_deployments_deployments_filter_post"]; export type DeploymentsPaginationFilter = diff --git a/ui-v2/src/components/deployments/deployment-details-page.tsx b/ui-v2/src/components/deployments/deployment-details-page.tsx index 6a4c0c37f6e7..b8348ae04bdb 100644 --- a/ui-v2/src/components/deployments/deployment-details-page.tsx +++ b/ui-v2/src/components/deployments/deployment-details-page.tsx @@ -15,17 +15,15 @@ import { DeploymentTriggers } from "./deployment-triggers"; import { RunFlowButton } from "./run-flow-button"; import { useDeleteDeploymentConfirmationDialog } from "./use-delete-deployment-confirmation-dialog"; -type Dialogs = "create" | "edit"; - type DeploymentDetailsPageProps = { id: string; }; export const DeploymentDetailsPage = ({ id }: DeploymentDetailsPageProps) => { - const [showScheduleDialog, setShowScheduleDialog] = useState( - null, - ); - const [scheduleIdToEdit, setScheduleIdToEdit] = useState(""); + const [showScheduleDialog, setShowScheduleDialog] = useState({ + open: false, + scheduleIdToEdit: "", + }); const { data: deployment } = useSuspenseQuery( buildDeploymentDetailsQuery(id), @@ -35,20 +33,20 @@ export const DeploymentDetailsPage = ({ id }: DeploymentDetailsPageProps) => { useDeleteDeploymentConfirmationDialog(); const scheduleToEdit = useMemo(() => { - if (!deployment.schedules) { + if (!deployment.schedules || !showScheduleDialog.scheduleIdToEdit) { return undefined; } return deployment.schedules.find( - (schedule) => schedule.id === scheduleIdToEdit, + (schedule) => schedule.id === showScheduleDialog.scheduleIdToEdit, ); - }, [deployment.schedules, scheduleIdToEdit]); + }, [deployment.schedules, showScheduleDialog.scheduleIdToEdit]); - const handleAddSchedule = () => setShowScheduleDialog("create"); - const handleEditSchedule = (scheduleId: string) => { - setScheduleIdToEdit(scheduleId); - setShowScheduleDialog("edit"); - }; - const closeDialog = () => setShowScheduleDialog(null); + const handleAddSchedule = () => + setShowScheduleDialog({ open: true, scheduleIdToEdit: "" }); + const handleEditSchedule = (scheduleId: string) => + setShowScheduleDialog({ open: true, scheduleIdToEdit: scheduleId }); + const closeDialog = () => + setShowScheduleDialog({ open: false, scheduleIdToEdit: "" }); const handleOpenChange = (open: boolean) => { // nb: Only need to handle when closing the dialog if (!open) { @@ -97,16 +95,13 @@ export const DeploymentDetailsPage = ({ id }: DeploymentDetailsPageProps) => { - {scheduleToEdit && ( - - )} + ); diff --git a/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-dialog/cron-schedule-form.test.tsx b/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-dialog/cron-schedule-form.test.tsx new file mode 100644 index 000000000000..20ab19b34b97 --- /dev/null +++ b/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-dialog/cron-schedule-form.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { Dialog } from "@/components/ui/dialog"; +import { Toaster } from "@/components/ui/toaster"; +import { createWrapper } from "@tests/utils"; +import { mockPointerEvents } from "@tests/utils/browser"; +import { + CronScheduleForm, + type CronScheduleFormProps, +} from "./cron-schedule-form"; + +const CronScheduleFormTest = (props: CronScheduleFormProps) => ( + <> + + + + + +); + +describe("CronScheduleForm", () => { + beforeAll(mockPointerEvents); + + it("is able to create a new cron schedule", async () => { + // Setup + const user = userEvent.setup(); + render(, { + wrapper: createWrapper(), + }); + + // Test + await user.click(screen.getByLabelText(/active/i)); + await user.clear(screen.getByLabelText(/value/i)); + await user.type(screen.getByLabelText(/value/i), "* * * * 1/2"); + await user.click(screen.getByLabelText(/day or/i)); + await user.click( + screen.getByRole("combobox", { name: /select timezone/i }), + ); + await user.click(screen.getByRole("option", { name: /africa \/ asmera/i })); + await user.click(screen.getByRole("button", { name: /save/i })); + + screen.logTestingPlaygroundURL(); + + // ------------ Assert + + expect(screen.getByLabelText(/active/i)).not.toBeChecked(); + expect(screen.getByLabelText(/value/i)).toHaveValue("* * * * 1/2"); + expect(screen.getByLabelText(/day or/i)).not.toBeChecked(); + }); + + it("is able to edit a new cron schedule", () => { + // Setup + const MOCK_SCHEDULE = { + active: true, + created: "0", + deployment_id: "0", + id: "123", + updated: "0", + schedule: { + cron: "* * * * 1/2", + day_or: true, + timezone: '"Etc/UTC"', + }, + }; + + render( + , + { wrapper: createWrapper() }, + ); + + // ------------ Assert + + expect(screen.getByLabelText(/active/i)).toBeChecked(); + expect(screen.getByLabelText(/value/i)).toHaveValue("* * * * 1/2"); + expect(screen.getByLabelText(/day or/i)).toBeChecked(); + }); +}); diff --git a/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-dialog/cron-schedule-form.tsx b/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-dialog/cron-schedule-form.tsx new file mode 100644 index 000000000000..28d63c40a248 --- /dev/null +++ b/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-dialog/cron-schedule-form.tsx @@ -0,0 +1,239 @@ +import { + useCreateDeploymentSchedule, + useUpdateDeploymentSchedule, +} from "@/api/deployments"; +import type { DeploymentSchedule } from "@/api/deployments"; +import { Button } from "@/components/ui/button"; +import { CronInput } from "@/components/ui/cron-input"; +import { DialogFooter, DialogTrigger } from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Switch } from "@/components/ui/switch"; +import { TimezoneSelect } from "@/components/ui/timezone-select"; +import { useToast } from "@/hooks/use-toast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import cronParser from "cron-parser"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const verifyCronValue = (cronValue: string) => { + try { + cronParser.parseExpression(cronValue); + return true; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + return false; + } +}; + +const formSchema = z.object({ + active: z.boolean(), + schedule: z.object({ + cron: z.string().refine(verifyCronValue), + timezone: z.string().default("Etc/UTC"), + day_or: z.boolean().default(true), + }), +}); +type FormSchema = z.infer; + +const DEFAULT_VALUES = { + active: true, + schedule: { + cron: "* * * * *", + timezone: "Etc/UTC", + day_or: true, + }, +} satisfies FormSchema; + +export type CronScheduleFormProps = { + deployment_id: string; + /** Schedule to edit. Pass undefined if creating a new limit */ + scheduleToEdit?: DeploymentSchedule; + /** Callback after hitting Save or Update */ + onSubmit: () => void; +}; + +export const CronScheduleForm = ({ + deployment_id, + scheduleToEdit, + onSubmit, +}: CronScheduleFormProps) => { + const { toast } = useToast(); + + const { createDeploymentSchedule, isPending: createPending } = + useCreateDeploymentSchedule(); + const { updateDeploymentSchedule, isPending: updatePending } = + useUpdateDeploymentSchedule(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: DEFAULT_VALUES, + }); + + // Sync form data with scheduleToEdit data + useEffect(() => { + if (scheduleToEdit) { + const { active, schedule } = scheduleToEdit; + if (!("cron" in schedule)) { + throw new Error("Expecting 'cron'"); + } + const { cron, day_or, timezone } = schedule; + form.reset({ + active, + schedule: { + cron, + day_or, + timezone: timezone ?? "Etc/UTC", + }, + }); + } else { + form.reset(DEFAULT_VALUES); + } + }, [form, scheduleToEdit]); + + const handleSave = (values: FormSchema) => { + const onSettled = () => { + form.reset(DEFAULT_VALUES); + onSubmit(); + }; + + if (scheduleToEdit) { + updateDeploymentSchedule( + { + deployment_id, + schedule_id: scheduleToEdit.id, + ...values, + }, + { + onSuccess: () => { + toast({ title: "Deployment schedule updated" }); + }, + onError: (error) => { + const message = + error.message || + "Unknown error while updating deployment schedule."; + form.setError("root", { message }); + }, + onSettled, + }, + ); + } else { + createDeploymentSchedule( + { + deployment_id, + ...values, + }, + { + onSuccess: () => { + toast({ title: "Deployment schedule created" }); + }, + onError: (error) => { + const message = + error.message || + "Unknown error while creating deployment schedule."; + form.setError("root", { + message, + }); + }, + onSettled, + }, + ); + } + }; + + return ( +
+ void form.handleSubmit(handleSave)(e)} + className="space-y-4" + > + {form.formState.errors.root?.message} +
+ ( + + Active + + + + + + )} + /> +
+
+ ( + + Value + + + + + + )} + /> +
+ ( + + Day Or + + + + + + )} + /> +
+ ( + + Timezone + + + + + + )} + /> +
+ + + + + + + +
+ + ); +}; diff --git a/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-dialog/deployment-schedule-dialog.tsx b/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-dialog/deployment-schedule-dialog.tsx index 777134861edd..3e98ce0d7375 100644 --- a/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-dialog/deployment-schedule-dialog.tsx +++ b/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-dialog/deployment-schedule-dialog.tsx @@ -1,4 +1,4 @@ -import type { DeploymentSchedule } from "@/components/deployments/deployment-schedules/types"; +import type { DeploymentSchedule } from "@/api/deployments"; import { Dialog, DialogContent, @@ -7,24 +7,44 @@ import { } from "@/components/ui/dialog"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { CronScheduleForm } from "./cron-schedule-form"; import { RRuleScheduleForm } from "./rrule-schedule-form"; type ScheduleTypes = "interval" | "cron" | "rrule"; type DeploymentScheduleDialogProps = { + deploymentId: string; onOpenChange: (open: boolean) => void; open: boolean; scheduleToEdit?: DeploymentSchedule; + onSubmit: () => void; }; export const DeploymentScheduleDialog = ({ + deploymentId, onOpenChange, open, scheduleToEdit, + onSubmit, }: DeploymentScheduleDialogProps) => { const [scheduleTab, setScheduleTab] = useState("interval"); + // sync tab with scheduleToEdit + useEffect(() => { + if (!scheduleToEdit) { + return; + } + const { schedule } = scheduleToEdit; + if ("interval" in schedule) { + setScheduleTab("interval"); + } else if ("cron" in schedule) { + setScheduleTab("cron"); + } else { + setScheduleTab("rrule"); + } + }, [scheduleToEdit]); + const SCHEDULE_TAB_OPTIONS = [ { value: "interval", @@ -34,7 +54,13 @@ export const DeploymentScheduleDialog = ({ { value: "cron", label: "Cron", - Component: () =>
TODO: Cron Form
, + Component: () => ( + + ), }, { value: "rrule", label: "RRule", Component: () => }, ] as const; diff --git a/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-item.tsx b/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-item.tsx index 810a3fcc432d..fe2befa2d97e 100644 --- a/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-item.tsx +++ b/ui-v2/src/components/deployments/deployment-schedules/deployment-schedule-item.tsx @@ -1,10 +1,10 @@ +import type { DeploymentSchedule } from "@/api/deployments"; import { Card } from "@/components/ui/card"; import { Typography } from "@/components/ui/typography"; import { getScheduleTitle } from "./get-schedule-title"; import { ScheduleActionMenu } from "./schedule-action-menu"; import { ScheduleToggleSwitch } from "./schedule-toggle-switch"; -import type { DeploymentSchedule } from "./types"; type DeploymentScheduleItemProps = { deploymentSchedule: DeploymentSchedule; diff --git a/ui-v2/src/components/deployments/deployment-schedules/get-schedule-title.test.ts b/ui-v2/src/components/deployments/deployment-schedules/get-schedule-title.test.ts index 48f1779bd78e..81beabbc0a0d 100644 --- a/ui-v2/src/components/deployments/deployment-schedules/get-schedule-title.test.ts +++ b/ui-v2/src/components/deployments/deployment-schedules/get-schedule-title.test.ts @@ -1,7 +1,7 @@ +import type { DeploymentSchedule } from "@/api/deployments"; import { faker } from "@faker-js/faker"; import { describe, expect, it } from "vitest"; import { getScheduleTitle } from "./get-schedule-title"; -import { DeploymentSchedule } from "./types"; describe("getScheduleTitle()", () => { const baseDeploymentSchedule = { diff --git a/ui-v2/src/components/deployments/deployment-schedules/get-schedule-title.ts b/ui-v2/src/components/deployments/deployment-schedules/get-schedule-title.ts index 0a6e20b23fe6..16f5e6153a0e 100644 --- a/ui-v2/src/components/deployments/deployment-schedules/get-schedule-title.ts +++ b/ui-v2/src/components/deployments/deployment-schedules/get-schedule-title.ts @@ -1,7 +1,7 @@ +import type { DeploymentSchedule } from "@/api/deployments"; import cronstrue from "cronstrue"; import humanizeDuration from "humanize-duration"; import { rrulestr } from "rrule"; -import type { DeploymentSchedule } from "./types"; export const getScheduleTitle = (deploymentSchedule: DeploymentSchedule) => { const { schedule } = deploymentSchedule; diff --git a/ui-v2/src/components/deployments/deployment-schedules/schedule-action-menu.tsx b/ui-v2/src/components/deployments/deployment-schedules/schedule-action-menu.tsx index 2c678178c9ae..6e4439bf7228 100644 --- a/ui-v2/src/components/deployments/deployment-schedules/schedule-action-menu.tsx +++ b/ui-v2/src/components/deployments/deployment-schedules/schedule-action-menu.tsx @@ -1,3 +1,4 @@ +import type { DeploymentSchedule } from "@/api/deployments"; import { Button } from "@/components/ui/button"; import { DeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog"; import { @@ -9,7 +10,6 @@ import { } from "@/components/ui/dropdown-menu"; import { Icon } from "@/components/ui/icons"; import { useToast } from "@/hooks/use-toast"; -import { DeploymentSchedule } from "./types"; import { useDeleteSchedule } from "./use-delete-schedule"; type ScheduleActionMenuProps = { diff --git a/ui-v2/src/components/deployments/deployment-schedules/schedule-toggle-switch.tsx b/ui-v2/src/components/deployments/deployment-schedules/schedule-toggle-switch.tsx index 6b545a7b8838..d7825e851ae6 100644 --- a/ui-v2/src/components/deployments/deployment-schedules/schedule-toggle-switch.tsx +++ b/ui-v2/src/components/deployments/deployment-schedules/schedule-toggle-switch.tsx @@ -1,8 +1,8 @@ import { useUpdateDeploymentSchedule } from "@/api/deployments"; +import type { DeploymentSchedule } from "@/api/deployments"; import { Switch } from "@/components/ui/switch"; import { useToast } from "@/hooks/use-toast"; import { getScheduleTitle } from "./get-schedule-title"; -import { DeploymentSchedule } from "./types"; type ScheduleToggleSwitchProps = { deploymentSchedule: DeploymentSchedule; diff --git a/ui-v2/src/components/deployments/deployment-schedules/types.ts b/ui-v2/src/components/deployments/deployment-schedules/types.ts deleted file mode 100644 index 8e7785e98964..000000000000 --- a/ui-v2/src/components/deployments/deployment-schedules/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Deployment } from "@/api/deployments"; - -export type DeploymentSchedule = Exclude< - Deployment["schedules"], - undefined ->[number]; diff --git a/ui-v2/src/components/deployments/deployment-schedules/use-delete-schedule.ts b/ui-v2/src/components/deployments/deployment-schedules/use-delete-schedule.ts index d8d26127a051..9e88c0e5cb91 100644 --- a/ui-v2/src/components/deployments/deployment-schedules/use-delete-schedule.ts +++ b/ui-v2/src/components/deployments/deployment-schedules/use-delete-schedule.ts @@ -1,8 +1,8 @@ import { useDeleteDeploymentSchedule } from "@/api/deployments"; +import type { DeploymentSchedule } from "@/api/deployments"; import { useDeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog"; import { useToast } from "@/hooks/use-toast"; import { getScheduleTitle } from "./get-schedule-title"; -import type { DeploymentSchedule } from "./types"; export const useDeleteSchedule = () => { const { toast } = useToast();