diff --git a/package-lock.json b/package-lock.json index f667e2a96..f7f1058a0 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 f3a35a518..2d47cfc52 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/Components/SelectPicker/FilterSelectPicker.tsx b/src/Shared/Components/SelectPicker/FilterSelectPicker.tsx index 1bfa388f3..1971725fd 100644 --- a/src/Shared/Components/SelectPicker/FilterSelectPicker.tsx +++ b/src/Shared/Components/SelectPicker/FilterSelectPicker.tsx @@ -34,6 +34,7 @@ const FilterSelectPicker = ({ options, menuIsOpen = false, onMenuClose, + isUserIdentifier, ...props }: FilterSelectPickerProps) => { const selectRef = useRef['selectRef']['current']>() @@ -110,6 +111,7 @@ const FilterSelectPicker = ({
{...props} + isUserIdentifier={isUserIdentifier} selectRef={selectRef} options={options} value={selectedOptions} diff --git a/src/Shared/Components/SelectPicker/SelectPicker.component.tsx b/src/Shared/Components/SelectPicker/SelectPicker.component.tsx index 72e2b2ef1..8251b3892 100644 --- a/src/Shared/Components/SelectPicker/SelectPicker.component.tsx +++ b/src/Shared/Components/SelectPicker/SelectPicker.component.tsx @@ -199,6 +199,7 @@ const SelectPicker = ({ menuPosition = 'fixed', variant = SelectPickerVariantType.DEFAULT, disableDescriptionEllipsis = false, + isUserIdentifier = false, multiSelectProps = {}, isMulti, name, @@ -302,9 +303,13 @@ const SelectPicker = ({ const renderOption = useCallback( (optionProps: OptionProps>) => ( - + ), - [disableDescriptionEllipsis], + [disableDescriptionEllipsis, isUserIdentifier], ) const renderMultiValue = (multiValueProps: MultiValueProps, true>) => ( diff --git a/src/Shared/Components/SelectPicker/common.tsx b/src/Shared/Components/SelectPicker/common.tsx index 38d0d32af..a10bb5335 100644 --- a/src/Shared/Components/SelectPicker/common.tsx +++ b/src/Shared/Components/SelectPicker/common.tsx @@ -32,11 +32,11 @@ import { ReactComponent as ICCaretDown } from '@Icons/ic-caret-down.svg' import { ReactComponent as ICClose } from '@Icons/ic-close.svg' import { Checkbox } from '@Common/Checkbox' import { ReactSelectInputAction } from '@Common/Constants' -import { noop } from '@Common/Helper' +import { getAlphabetIcon, noop } from '@Common/Helper' import { Progressing } from '@Common/Progressing' import { Tooltip, TooltipProps } from '@Common/Tooltip' import { CHECKBOX_VALUE } from '@Common/Types' -import { ComponentSizeType } from '@Shared/constants' +import { API_TOKEN_PREFIX, ComponentSizeType } from '@Shared/constants' import { isNullOrUndefined } from '@Shared/Helpers' import { Button, ButtonProps, ButtonVariantType } from '../Button' @@ -191,9 +191,10 @@ export const SelectPickerValueContainer = export const SelectPickerOption = ({ disableDescriptionEllipsis, + isUserIdentifier, ...props }: OptionProps> & - Pick, 'disableDescriptionEllipsis'>) => { + Pick, 'disableDescriptionEllipsis' | 'isUserIdentifier'>) => { const { label, data, @@ -215,17 +216,47 @@ export const SelectPickerOption = ({ const iconBaseClass = 'dc__no-shrink icon-dim-16 flex dc__fill-available-space' + const showUserAvatar = isUserIdentifier && !isSelected && typeof label === 'string' + + const renderLabelText = () => { + if (showUserAvatar && label.startsWith(API_TOKEN_PREFIX)) { + return label.split(':')?.[1] || '-' + } + + return label + } + + const renderAvatar = () => { + if (!showUserAvatar) { + return null + } + + return ( +
+ {label.startsWith(API_TOKEN_PREFIX) ? ( + + ) : ( + getAlphabetIcon(label, 'dc__no-shrink m-0-imp') + )} +
+ ) + } + return ( - +
+ {showUserAvatar && renderAvatar()} {isMulti && showCheckboxForMultiSelect && !isCreatableOption && ( )} @@ -243,7 +274,7 @@ export const SelectPickerOption = ({

- {label} + {renderLabelText()}

{/* Add support for custom ellipsis if required */} diff --git a/src/Shared/Components/SelectPicker/type.ts b/src/Shared/Components/SelectPicker/type.ts index f32436e92..e09ce9ee4 100644 --- a/src/Shared/Components/SelectPicker/type.ts +++ b/src/Shared/Components/SelectPicker/type.ts @@ -310,6 +310,7 @@ export type SelectPickerProps, 'customDisplayText'>> & { /** * If true, the group heading can be selected @@ -328,6 +329,7 @@ export type SelectPickerProps { appliedFilterOptions: SelectPickerOptionType[] handleApplyFilter: (filtersToApply: SelectPickerOptionType[]) => void 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/Services/common.service.ts b/src/Shared/Services/common.service.ts index 2a3b5983d..2324542bc 100644 --- a/src/Shared/Services/common.service.ts +++ b/src/Shared/Services/common.service.ts @@ -47,8 +47,8 @@ export const saveCDPipeline = (request, { isTemplateView }: Required get(ROUTES.ENVIRONMENT_DATA) -export const getClusterOptions = async (): Promise => { - const { result } = await get(ROUTES.CLUSTER_LIST_MIN) +export const getClusterOptions = async (signal?: AbortSignal): Promise => { + const { result } = await get(ROUTES.CLUSTER_LIST_MIN, { signal }) if (!result) { return [] 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', } }