Skip to content

Commit

Permalink
[UI v2] feat: Adds deployment schedule list (except for adding end ed…
Browse files Browse the repository at this point in the history
…iting UX) (#17081)
  • Loading branch information
devinvillarosa authored Feb 11, 2025
1 parent 704995b commit 039693f
Show file tree
Hide file tree
Showing 13 changed files with 477 additions and 3 deletions.
5 changes: 2 additions & 3 deletions ui-v2/src/components/deployments/deployment-details-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { DeploymentDetailsHeader } from "./deployment-details-header";
import { DeploymentDetailsTabs } from "./deployment-details-tabs";
import { DeploymentFlowLink } from "./deployment-flow-link";
import { DeploymentMetadata } from "./deployment-metadata";
import { DeploymentSchedules } from "./deployment-schedules/deployment-schedules";
import { DeploymentTriggers } from "./deployment-triggers";
import { RunFlowButton } from "./run-flow-button";
import { useDeleteDeploymentConfirmationDialog } from "./use-delete-deployment-confirmation-dialog";
Expand Down Expand Up @@ -41,9 +42,7 @@ export const DeploymentDetailsPage = ({ id }: DeploymentDetailsPageProps) => {
<DeploymentDetailsTabs deployment={data} />
</div>
<div className="flex flex-col gap-3">
<div className="border border-red-400">
{"<SchedulesSection />"}
</div>
<DeploymentSchedules deployment={data} />
<DeploymentTriggers deployment={data} />
<hr />
<DeploymentMetadata deployment={data} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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;
};

export const DeploymentScheduleItem = ({
deploymentSchedule,
}: DeploymentScheduleItemProps) => {
return (
<Card className="p-3 flex items-center justify-between">
<Typography>{getScheduleTitle(deploymentSchedule)}</Typography>
<div className="flex items-center gap-2">
<ScheduleToggleSwitch deploymentSchedule={deploymentSchedule} />
<ScheduleActionMenu deploymentSchedule={deploymentSchedule} />
</div>
</Card>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {
reactQueryDecorator,
routerDecorator,
toastDecorator,
} from "@/storybook/utils";
import type { Meta, StoryObj } from "@storybook/react";

import { createFakeDeployment } from "@/mocks";
import { faker } from "@faker-js/faker";
import { DeploymentSchedules } from "./deployment-schedules";

const baseDeploymentSchedule = {
id: faker.string.uuid(),
created: faker.date.recent().toISOString(),
updated: faker.date.recent().toISOString(),
deployment_id: faker.string.uuid(),
active: true,
max_scheduled_runs: null,
};

const MOCK_DEPLOYMENT = createFakeDeployment({
schedules: [
{
...baseDeploymentSchedule,
schedule: {
cron: "1 * * * *",
timezone: "UTC",
day_or: true,
},
},
{
...baseDeploymentSchedule,
schedule: {
cron: "1 * * * *",
timezone: "UTC",
day_or: true,
},
},
{
...baseDeploymentSchedule,
schedule: {
rrule: "FREQ=DAILY;COUNT=5",
timezone: "UTC",
},
},
],
});

const meta = {
title: "Components/Deployments/DeploymentSchedules",
component: DeploymentSchedules,
decorators: [toastDecorator, routerDecorator, reactQueryDecorator],
args: {
deployment: MOCK_DEPLOYMENT,
},
} satisfies Meta<typeof DeploymentSchedules>;

export default meta;

export const story: StoryObj = { name: "DeploymentSchedules" };
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Deployment } from "@/api/deployments";
import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icons";
import { useMemo } from "react";
import { DeploymentScheduleItem } from "./deployment-schedule-item";

type DeploymentSchedulesProps = {
deployment: Deployment;
};

export const DeploymentSchedules = ({
deployment,
}: DeploymentSchedulesProps) => {
// nb: Need to sort by created, because API re-arranges order per last update
const deploymentSchedulesSorted = useMemo(() => {
if (!deployment.schedules) {
return [];
}
return deployment.schedules.sort((a, b) => {
if (!a.created) {
return -1;
}
if (!b.created) {
return 1;
}
return Date.parse(a.created) - Date.parse(b.created);
});
}, [deployment.schedules]);

return (
<div className="flex flex-col gap-1">
<div className="text-sm text-muted-foreground">Schedules</div>
<div className="flex flex-col gap-2">
{deploymentSchedulesSorted.map((schedule) => (
<DeploymentScheduleItem
key={schedule.id}
deploymentSchedule={schedule}
/>
))}
<div>
<Button size="sm">
<Icon id="Plus" className="mr-2 h-4 w-4" /> Schedule
</Button>
</div>
</div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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 = {
id: faker.string.uuid(),
created: faker.date.recent().toISOString(),
updated: faker.date.recent().toISOString(),
deployment_id: faker.string.uuid(),
active: true,
max_scheduled_runs: null,
};

it("returns an interval formatted title", () => {
const mockDeploymentSchedule: DeploymentSchedule = {
...baseDeploymentSchedule,
schedule: {
interval: 3600,
anchor_date: faker.date.recent().toISOString(),
timezone: "UTC",
},
};

// TEST
const RESULT = getScheduleTitle(mockDeploymentSchedule);

// ASSERT
const EXPECTED = "Every 1 hour";
expect(RESULT).toEqual(EXPECTED);
});

it("returns a cron formatted title", () => {
const mockDeploymentSchedule: DeploymentSchedule = {
...baseDeploymentSchedule,
schedule: {
cron: "1 * * * *",
timezone: "UTC",
day_or: true,
},
};

// TEST
const RESULT = getScheduleTitle(mockDeploymentSchedule);

// ASSERT
const EXPECTED = "At 1 minutes past the hour";
expect(RESULT).toEqual(EXPECTED);
});

it("returns a rrule formatted title", () => {
const mockDeploymentSchedule: DeploymentSchedule = {
...baseDeploymentSchedule,
schedule: {
rrule: "FREQ=DAILY;COUNT=5",
timezone: "UTC",
},
};

// TEST
const RESULT = getScheduleTitle(mockDeploymentSchedule);

// ASSERT
const EXPECTED = "every day for 5 times";
expect(RESULT).toEqual(EXPECTED);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
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;
if ("interval" in schedule) {
return `Every ${humanizeDuration(schedule.interval * 1_000)}`;
}
if ("cron" in schedule) {
return cronstrue.toString(schedule.cron);
}
return rrulestr(schedule.rrule).toText();
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { DeploymentSchedules } from "./deployment-schedules";
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Toaster } from "@/components/ui/toaster";
import { faker } from "@faker-js/faker";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";

import { createWrapper } from "@tests/utils";
import { ScheduleActionMenu } from "./schedule-action-menu";

const MOCK_DEPLOYMENT_SCHEDULE = {
id: faker.string.uuid(),
created: faker.date.recent().toISOString(),
updated: faker.date.recent().toISOString(),
deployment_id: faker.string.uuid(),
active: true,
max_scheduled_runs: null,
schedule: {
cron: "1 * * * *",
timezone: "UTC",
day_or: true,
},
};

describe("ScheduleActionMenu", () => {
it("copies the id", async () => {
// ------------ Setup
const user = userEvent.setup();
render(
<>
<Toaster />
<ScheduleActionMenu deploymentSchedule={MOCK_DEPLOYMENT_SCHEDULE} />
</>,
{ wrapper: createWrapper() },
);

// ------------ Act
await user.click(
screen.getByRole("button", { name: /open menu/i, hidden: true }),
);
await user.click(screen.getByRole("menuitem", { name: "Copy ID" }));

// ------------ Assert
expect(screen.getByText("ID copied")).toBeVisible();
});

it("calls delete option and deletes schedule", async () => {
// ------------ Setup
const user = userEvent.setup();
render(
<>
<Toaster />
<ScheduleActionMenu deploymentSchedule={MOCK_DEPLOYMENT_SCHEDULE} />
</>,
{ wrapper: createWrapper() },
);
// ------------ Act

await user.click(
screen.getByRole("button", { name: /open menu/i, hidden: true }),
);
await user.click(screen.getByRole("menuitem", { name: /delete/i }));

await user.click(screen.getByRole("button", { name: /delete/i }));

// ------------ Assert
await waitFor(() =>
expect(screen.getByText("Schedule deleted")).toBeVisible(),
);
expect(
screen.queryByRole("heading", { name: /delete schedule/i }),
).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Button } from "@/components/ui/button";
import { DeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} 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 = {
deploymentSchedule: DeploymentSchedule;
};

export const ScheduleActionMenu = ({
deploymentSchedule,
}: ScheduleActionMenuProps) => {
const { toast } = useToast();
const [dialogState, confirmDelete] = useDeleteSchedule();
const handleCopyId = (id: string) => {
void navigator.clipboard.writeText(id);
toast({ title: "ID copied" });
};

const handleEdit = () => {};

const handleDelete = () => confirmDelete(deploymentSchedule);

return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<Icon id="MoreVertical" className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => handleCopyId(deploymentSchedule.id)}>
Copy ID
</DropdownMenuItem>
<DropdownMenuItem onClick={handleEdit}>Edit</DropdownMenuItem>
<DropdownMenuItem onClick={handleDelete}>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DeleteConfirmationDialog {...dialogState} />
</>
);
};
Loading

0 comments on commit 039693f

Please sign in to comment.