diff --git a/ui-v2/package-lock.json b/ui-v2/package-lock.json index d7d09ef6d7ac..edb975d23c78 100644 --- a/ui-v2/package-lock.json +++ b/ui-v2/package-lock.json @@ -39,6 +39,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.4", + "cron-parser": "^4.9.0", "cronstrue": "^2.54.0", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", @@ -6323,6 +6324,18 @@ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cronstrue": { "version": "2.54.0", "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.54.0.tgz", @@ -8908,6 +8921,15 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/luxon": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/ui-v2/package.json b/ui-v2/package.json index a2b109227e0c..60a05e21b4ff 100644 --- a/ui-v2/package.json +++ b/ui-v2/package.json @@ -49,6 +49,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.4", + "cron-parser": "^4.9.0", "cronstrue": "^2.54.0", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", diff --git a/ui-v2/src/components/ui/cron-input/cron-input.stories.tsx b/ui-v2/src/components/ui/cron-input/cron-input.stories.tsx new file mode 100644 index 000000000000..b3c39fd2b84e --- /dev/null +++ b/ui-v2/src/components/ui/cron-input/cron-input.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { useState } from "react"; +import { CronInput } from "./cron-input"; + +const meta: Meta = { + title: "UI/CronInput", + render: () => , +}; + +export default meta; + +export const story: StoryObj = { name: "CronInput" }; + +const CronInputStory = () => { + const [input, setInput] = useState("* * * * *"); + + return ( + setInput(e.target.value)} + getIsCronValid={fn} + /> + ); +}; diff --git a/ui-v2/src/components/ui/cron-input/cron-input.test.tsx b/ui-v2/src/components/ui/cron-input/cron-input.test.tsx new file mode 100644 index 000000000000..ccf1e75b5a30 --- /dev/null +++ b/ui-v2/src/components/ui/cron-input/cron-input.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { CronInput, type CronInputProps } from "./cron-input"; + +describe("CronInput", () => { + const TestCronInput = ({ getIsCronValid }: CronInputProps) => { + const [value, setValue] = useState(""); + return ( + setValue(e.target.value)} + getIsCronValid={getIsCronValid} + /> + ); + }; + + it("renders a valid cron message", async () => { + // SETUP + const user = userEvent.setup(); + const mockGetIsCronValid = vi.fn(); + render(); + + // TEST + await user.type(screen.getByRole("textbox"), "* * * * *"); + + // ASSERT + expect(screen.getByText("Every minute")).toBeVisible(); + expect(mockGetIsCronValid).toHaveBeenLastCalledWith(true); + }); + + it("renders an valid cron message", async () => { + // SETUP + const user = userEvent.setup(); + const mockGetIsCronValid = vi.fn(); + render(); + + // TEST + await user.type(screen.getByRole("textbox"), "abcd"); + + // ASSERT + expect(screen.getByText("Invalid expression")).toBeVisible(); + expect(mockGetIsCronValid).toHaveBeenLastCalledWith(false); + }); +}); diff --git a/ui-v2/src/components/ui/cron-input/cron-input.tsx b/ui-v2/src/components/ui/cron-input/cron-input.tsx new file mode 100644 index 000000000000..6e5e172c4cf3 --- /dev/null +++ b/ui-v2/src/components/ui/cron-input/cron-input.tsx @@ -0,0 +1,64 @@ +import { Input, type InputProps } from "@/components/ui/input"; +import { Typography } from "@/components/ui/typography"; +import clsx from "clsx"; +import cronParser from "cron-parser"; +import cronstrue from "cronstrue"; +import { useState } from "react"; + +const verifyCronValue = (cronValue: string) => { + let description = ""; + let isCronValid = false; + try { + cronParser.parseExpression(cronValue); + description = cronstrue.toString(cronValue); + isCronValid = true; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + isCronValid = false; + description = "Invalid expression"; + } + return { + description, + isCronValid, + }; +}; + +export type CronInputProps = InputProps & { + /** Used to indicate the container if Cron is valid */ + getIsCronValid?: (isValid: boolean) => void; +}; + +export const CronInput = ({ + getIsCronValid = () => true, + onChange, + ...props +}: CronInputProps) => { + const [description, setDescription] = useState( + verifyCronValue(String(props.value)).description, + ); + const [isCronValid, setIsCronValid] = useState( + verifyCronValue(String(props.value)).isCronValid, + ); + + const handleChange: React.ChangeEventHandler = (event) => { + if (onChange) { + onChange(event); + const { description, isCronValid } = verifyCronValue(event.target.value); + setDescription(description); + setIsCronValid(isCronValid); + getIsCronValid(isCronValid); + } + }; + + return ( +
+ + + {description} + +
+ ); +}; diff --git a/ui-v2/src/components/ui/cron-input/index.ts b/ui-v2/src/components/ui/cron-input/index.ts new file mode 100644 index 000000000000..7223bdd9ec9f --- /dev/null +++ b/ui-v2/src/components/ui/cron-input/index.ts @@ -0,0 +1 @@ +export { CronInput } from "./cron-input";