diff --git a/package-lock.json b/package-lock.json index 5a8d826bc..94dcb64a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@codemirror/lint": "6.8.4", "@codemirror/merge": "^6.10.0", "@codemirror/search": "6.5.8", + "@datasert/cronjs-parser": "^1.4.0", "@lezer/highlight": "1.2.1", "@replit/codemirror-indentation-markers": "6.5.3", "@replit/codemirror-vscode-keymap": "6.0.2", @@ -29,6 +30,7 @@ "ansi_up": "^5.2.1", "chart.js": "^4.5.0", "codemirror-json-schema": "0.8.0", + "cronstrue": "^3.9.0", "dayjs": "^1.11.13", "fast-json-patch": "^3.1.1", "focus-trap-react": "^10.3.1", @@ -697,6 +699,12 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@datasert/cronjs-parser": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@datasert/cronjs-parser/-/cronjs-parser-1.4.0.tgz", + "integrity": "sha512-zHGlrWanS4Zjgf0aMi/sp/HTSa2xWDEtXW9xshhlGf/jPx3zTIqfX14PZnoFF7XVOwzC49Zy0SFWG90rlRY36Q==", + "license": "MIT" + }, "node_modules/@emnapi/runtime": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", @@ -5633,6 +5641,15 @@ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" }, + "node_modules/cronstrue": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-3.9.0.tgz", + "integrity": "sha512-T3S35zmD0Ai2B4ko6+mEM+k9C6tipe2nB9RLiGT6QL2Wn0Vsn2cCZAC8Oeuf4CaE00GZWVdpYitbpWCNlIWqdA==", + "license": "MIT", + "bin": { + "cronstrue": "bin/cli.js" + } + }, "node_modules/cross-spawn": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz", diff --git a/package.json b/package.json index 3625d2ac5..566a30250 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "@codemirror/lint": "6.8.4", "@codemirror/merge": "^6.10.0", "@codemirror/search": "6.5.8", + "@datasert/cronjs-parser": "^1.4.0", "@lezer/highlight": "1.2.1", "@replit/codemirror-indentation-markers": "6.5.3", "@replit/codemirror-vscode-keymap": "6.0.2", @@ -119,6 +120,7 @@ "ansi_up": "^5.2.1", "chart.js": "^4.5.0", "codemirror-json-schema": "0.8.0", + "cronstrue": "^3.9.0", "dayjs": "^1.11.13", "fast-json-patch": "^3.1.1", "focus-trap-react": "^10.3.1", diff --git a/src/Shared/Helpers.tsx b/src/Shared/Helpers.tsx index 2970db166..be06ee862 100644 --- a/src/Shared/Helpers.tsx +++ b/src/Shared/Helpers.tsx @@ -17,8 +17,10 @@ /* eslint-disable no-param-reassign */ import { ReactElement, useEffect, useRef, useState } from 'react' import { PromptProps } from 'react-router-dom' +import { parse as parseCronExpression } from '@datasert/cronjs-parser' import { StrictRJSFSchema } from '@rjsf/utils' import Tippy from '@tippyjs/react' +import cronstrue from 'cronstrue' import { animate } from 'framer-motion' import moment from 'moment' import { nanoid } from 'nanoid' @@ -752,3 +754,16 @@ export const formatNumberToCurrency = (value: number, currency: string, minimumF return value.toFixed(precision) } } + +/** + * Returns the human readable explanation of the expression + * NOTE: expectation is that the expression is valid + * + * @throws Error - if given expression is incorrect + * @param expression + * @returns string - helper text explaining the expression in a human readable format + */ +export const explainCronExpression = (expression: string): string => { + parseCronExpression(expression, { hasSeconds: expression.trim().split(' ').length > 5 }) + return cronstrue.toString(expression) +} diff --git a/src/Shared/validations.tsx b/src/Shared/validations.tsx index 419b773e2..1f3d936a0 100644 --- a/src/Shared/validations.tsx +++ b/src/Shared/validations.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import { parse as parseCronExpression } from '@datasert/cronjs-parser' import { customizeValidator } from '@rjsf/validator-ajv8' import { parse } from 'yaml' @@ -546,31 +547,17 @@ export const getIsRegexValid = (regexString: string): ValidationResponseType => } } -export const validateCronExpression = (cron: string): ValidationResponseType => { - // Basic cron validation - 5 parts separated by spaces - const parts = cron.trim().split(/\s+/) - if (parts.length !== 5) { - return { isValid: false, message: 'Cron expression must have 5 parts separated by spaces' } - } - - const isValid = parts.every((part) => { - if (part === '*') return true - if (/^\d+$/.test(part)) return true - if (/^\d+-\d+$/.test(part)) return true - if (/^\*\/\d+$/.test(part)) return true - if (/^(\d+,)+\d+$/.test(part)) return true - return false - }) +export const validateCronExpression = (expression: string): ValidationResponseType => { + try { + parseCronExpression(expression, { hasSeconds: expression.trim().split(' ').length > 5 }) - // Basic validation - each part should be either * or a number or a range - if (isValid) { return { - isValid, + isValid: true, + } + } catch (err) { + return { + isValid: false, + message: (err as Error).message, } - } - - return { - isValid: false, - message: 'Invalid cron expression format', } }