├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md └── workflows │ ├── build.yml │ ├── lint.yml │ ├── pages.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── .prettierrc.js ├── .storybook ├── main.js ├── manager-head.html ├── manager.js ├── preview-head.html └── preview.js ├── .yarn └── releases │ └── yarn-3.3.0.cjs ├── .yarnrc.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── package.json ├── react-js-cron-example.png ├── rollup.config.mjs ├── src ├── Cron.tsx ├── components │ └── CustomSelect.tsx ├── constants.ts ├── converter.ts ├── fields │ ├── Hours.tsx │ ├── Minutes.tsx │ ├── MonthDays.tsx │ ├── Months.tsx │ ├── Period.tsx │ └── WeekDays.tsx ├── index.ts ├── locale.ts ├── setupTests.js ├── stories │ ├── constants.stories.ts │ ├── index.stories.tsx │ ├── styles.stories.css │ └── utils.stories.ts ├── styles.css ├── tests │ ├── Cron.defaultValue.test.tsx │ ├── Cron.updateValue.test.tsx │ ├── __snapshots__ │ │ └── fields.test.tsx.snap │ └── fields.test.tsx ├── types.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "ecmaVersion": 2020, 5 | "sourceType": "module", 6 | "project": "./tsconfig.json", 7 | "ecmaFeatures": { 8 | "jsx": true 9 | } 10 | }, 11 | "plugins": ["@typescript-eslint", "react-hooks", "file-progress"], 12 | "extends": [ 13 | "plugin:react/recommended", 14 | "plugin:@typescript-eslint/recommended", 15 | "prettier" 16 | ], 17 | "settings": { 18 | "react": { 19 | "version": "detect" 20 | } 21 | }, 22 | "rules": { 23 | "@typescript-eslint/camelcase": "off", 24 | "@typescript-eslint/explicit-function-return-type": "off", 25 | "@typescript-eslint/explicit-module-boundary-types": "off", 26 | "@typescript-eslint/interface-name-prefix": "off", 27 | "@typescript-eslint/no-empty-interface": "off", 28 | "@typescript-eslint/no-explicit-any": "off", 29 | "@typescript-eslint/no-non-null-assertion": "off", 30 | "@typescript-eslint/no-unused-vars": [ 31 | "error", 32 | { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" } 33 | ], 34 | "@typescript-eslint/no-use-before-define": "off", 35 | "file-progress/activate": "warn", 36 | "react-hooks/exhaustive-deps": "error", 37 | "react-hooks/rules-of-hooks": "error", 38 | "react/display-name": "off", 39 | "react/prop-types": "off", 40 | "react/react-in-jsx-scope": "off" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **CodeSandbox** 27 | A CodeSandbox link with the bug. 28 | 29 | **Versions (please complete the following information):** 30 | - react-js-cron version [e.g. 1.2.0] 31 | - Ant Design version: [e.g. 4.15.0] 32 | - React version: [e.g. 17.0.0] 33 | - OS: [e.g. iOS] 34 | - Browser [e.g. chrome, safari] 35 | 36 | **Additional context** 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 4 | 5 | ### 🤔 This is a ... 6 | 7 | - [ ] New feature 8 | - [ ] Bug fix 9 | - [ ] Demo update 10 | - [ ] Component style update 11 | - [ ] TypeScript definition update 12 | - [ ] Bundle size optimization 13 | - [ ] Performance optimization 14 | - [ ] Refactoring 15 | - [ ] Code style optimization 16 | - [ ] Test Case 17 | - [ ] README update 18 | - [ ] Other (about what?) 19 | 20 | ### 🔗 Related issue link 21 | 22 | 25 | 26 | ### 💡 Background and solution 27 | 28 | 33 | 34 | ### ☑️ Self Check before Merge 35 | 36 | ⚠️ Please check all items below before review. ⚠️ 37 | 38 | - [ ] Demo in storybook is updated/provided or not needed 39 | - [ ] TypeScript definition is updated/provided or not needed 40 | - [ ] Tests are updated and passed without a decrease in coverage 41 | - [ ] Format (lint & prettier) script passed 42 | - [ ] Build script is working 43 | - [ ] README API section is updated or not needed 44 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | name: Build package 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Install dependencies 12 | run: yarn --immutable 13 | - name: Run build 14 | run: yarn build 15 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | name: Lint code 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Install dependencies 12 | run: yarn --immutable 13 | - name: Run lint and prettier 14 | run: yarn format 15 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: Storybook 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | storybook: 10 | runs-on: ubuntu-latest 11 | name: Build Storybook 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Install dependencies 15 | run: yarn --immutable 16 | - name: Run build-storybook 17 | run: yarn build-storybook 18 | - name: Deploy 19 | uses: JamesIves/github-pages-deploy-action@v4 20 | with: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | BRANCH: gh-pages # The branch the action should deploy to. 23 | FOLDER: docs # The folder that the build-storybook script generates files. 24 | CLEAN: true # Automatically remove deleted files from the deploy branch 25 | FORCE: true # Force-push new deployments to overwrite the previous version 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | name: Unit tests 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Install dependencies 12 | run: yarn --immutable 13 | - name: Run tests and collect coverage 14 | run: yarn test --coverage --forceExit --detectOpenHandles 15 | - name: Upload coverage reports to Codecov 16 | run: | 17 | curl -Os https://uploader.codecov.io/latest/linux/codecov 18 | chmod +x codecov 19 | ./codecov 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore 2 | 3 | ## root folders & dot-files 4 | /*/ 5 | .* 6 | 7 | ## logs 8 | *.log* 9 | 10 | ## yarn 11 | .yarn/cache/ 12 | .yarn/install-state.gz 13 | 14 | ## npm 15 | package-lock.json 16 | 17 | ## package 18 | *.tgz 19 | 20 | # Authorize 21 | 22 | ## folders 23 | !/.github/ 24 | !/.storybook/ 25 | !/.yarn/ 26 | !/src/ 27 | 28 | ## dot-files 29 | !/.babelrc 30 | !/.eslintrc 31 | !/.npmignore 32 | !/.prettierrc.js 33 | !/.yarnrc.yml 34 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/dist 3 | /dist/**/stories 4 | /dist/cjs/styles.css 5 | /dist/esm/styles.css 6 | /dist/**/setupTests.js 7 | /dist/**/*.test.* 8 | !/src 9 | /src/**/stories 10 | /src/tests 11 | /src/**/*.test.* 12 | /src/setupTests.js 13 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | jsxSingleQuote: true, 4 | quoteProps: 'consistent', 5 | semi: false, 6 | importOrder: [ 7 | '^[!^./]', // imports from external packages 8 | '^[./].*$', // imports from internal source code 9 | ], 10 | importOrderSeparation: true, 11 | importOrderSortSpecifiers: true, 12 | } 13 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 3 | addons: [ 4 | '@storybook/addon-links', 5 | '@storybook/addon-essentials', 6 | '@storybook/addon-interactions', 7 | ], 8 | framework: '@storybook/react', 9 | core: { 10 | builder: '@storybook/builder-webpack5', 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /.storybook/manager-head.html: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /.storybook/manager.js: -------------------------------------------------------------------------------- 1 | import addons from '@storybook/addons' 2 | import { STORY_RENDERED } from '@storybook/core-events' 3 | 4 | addons.register('TitleAddon', (api) => { 5 | api.on(STORY_RENDERED, () => { 6 | document.title = 'ReactJS Cron' 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import 'antd/dist/reset.css' 2 | 3 | export const parameters = { 4 | actions: { argTypesRegex: '^on[A-Z].*' }, 5 | controls: { 6 | matchers: { 7 | color: /(background|color)$/i, 8 | date: /Date$/, 9 | }, 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | defaultSemverRangePrefix: '' 2 | 3 | enableStrictSsl: false 4 | 5 | nodeLinker: node-modules 6 | 7 | yarnPath: .yarn/releases/yarn-3.3.0.cjs 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Changelog 2 | 3 | `react-js-cron` follows [Semantic Versioning 2.0.0](http://semver.org/). 4 | 5 | #### 5.2.0 6 | 7 | - **(New feature)** Export converter `parseCronString` function to allow third party use (@idpaterson) 8 | 9 | #### 5.1.1 10 | 11 | - Fix issue [#71](https://github.com/xrutayisire/react-js-cron/issues/71): Fix empty space in each field with no selection (@iyapici-wallix) 12 | 13 | #### 5.1.0 14 | 15 | - **(New feature)** Issue [#75](https://github.com/xrutayisire/react-js-cron/issues/75): Add `getPopupContainer` from antd to Select component (@silannisik) 16 | 17 | #### 5.0.1 18 | 19 | - Fix `dropdownsConfig` `filterOption` prop not working for months and week-days 20 | 21 | #### 5.0.0 22 | 23 | - **(Breaking change!)** Fix issue [#60](https://github.com/xrutayisire/react-js-cron/issues/60): react-js-cron now only support antd >= v5.8.0, use "suffixIcon" instead of deprecated "showArrow" for Select component 24 | 25 | #### 4.1.0 26 | 27 | - **(New feature)** Issue [#36](https://github.com/xrutayisire/react-js-cron/issues/36): Dropdowns specific configuration (mode, allowClear, filterOption, ...) 28 | - Fix dropdowns popover style 29 | 30 | #### 4.0.0 31 | 32 | - **(Breaking change!)** Fix issue [#55](https://github.com/xrutayisire/react-js-cron/issues/55): react-js-cron now only support antd >= v5.5.0, change antd Select property "dropdownMatchSelectWidth" to "popupMatchSelectWidth" 33 | 34 | #### 3.2.0 35 | 36 | - Add themes support for antd v5 with ConfigProvider (@nefedov-dm) 37 | 38 | #### 3.1.0 39 | 40 | - Export converter (@useafterfree) 41 | 42 | #### 3.0.1 43 | 44 | - **(Important)** Issue [#40](https://github.com/xrutayisire/react-js-cron/issues/40): Antd v5 compatibility 45 | - **(Breaking change!)** Fix issue [#35](https://github.com/xrutayisire/react-js-cron/issues/35): react-js-cron now only support antd >= v4.23.0, change antd props dropdownClassName 46 | - Fix issue [#41](https://github.com/xrutayisire/react-js-cron/issues/41): Fix broken build with react-js-cron v3.0.0 47 | 48 | #### 3.0.0 49 | 50 | ❌ Do not use this release, broken build ❌ 51 | 52 | #### 2.1.2 53 | 54 | - Fix issue [#31](https://github.com/xrutayisire/react-js-cron/issues/31): Fix cron parsing accepting incorrect values 55 | 56 | #### 2.1.1 57 | 58 | - Fix issue [#30](https://github.com/xrutayisire/react-js-cron/issues/30): Fix invalid expression parsing with multiple ranges 59 | 60 | #### 2.1.0 61 | 62 | - **(New feature)** Issue [#28](https://github.com/xrutayisire/react-js-cron/issues/28): Add an extra param in setValue function to always know the selected period 63 | 64 | #### 2.0.0 65 | 66 | - **(Breaking change!)** Issue [#19](https://github.com/xrutayisire/react-js-cron/issues/19): Remove CSS import in Cron component to support Next.js. **It's now required to manually import "react-js-cron/dist/styles.css" file!** 67 | - **(Breaking change!)** Issue [#22](https://github.com/xrutayisire/react-js-cron/issues/22): periodicityOnDoubleClick is not ignored anymore when single mode is active 68 | - **(New feature)** Issue [#22](https://github.com/xrutayisire/react-js-cron/issues/22): Automatically close a dropdown when single mode is active and periodicityOnDoubleClick is false 69 | - **(New feature)** Issue [#28](https://github.com/xrutayisire/react-js-cron/issues/28): Add the possibility to restrict visible dropdowns and periods 70 | 71 | #### 1.4.0 72 | 73 | - **(New feature)** Issue [#22](https://github.com/xrutayisire/react-js-cron/issues/22): Add the possibility to choose a single selection mode 74 | 75 | #### 1.3.1 76 | 77 | - Fix disabled mode broken since antd 13.0 78 | - Fix issue [#15](https://github.com/xrutayisire/react-js-cron/issues/15): Fix double setValue function call that prevents changing value 79 | 80 | #### 1.3.0 81 | 82 | - **(New feature)** Issue [#12](https://github.com/xrutayisire/react-js-cron/issues/12): Add the possibility to deactivate the double click feature 83 | - Fix antd automatic tree-shaking not working in many cases (Missing Select import) 84 | - Fix to allow equal min and max range values 85 | 86 | #### 1.2.1 87 | 88 | - Fix issue [#6](https://github.com/xrutayisire/react-js-cron/issues/6): Display problem for Selects with antd 4.10.0 89 | - Fix placeholder color that should not be gray but black as the value 90 | 91 | #### 1.2.0 92 | 93 | - **(New feature)** Issue [#3](https://github.com/xrutayisire/react-js-cron/issues/3): Add the possibility to translate alternative labels 94 | - Fix typo in README 95 | 96 | #### 1.1.1 97 | 98 | - Fix issue [#2](https://github.com/xrutayisire/react-js-cron/issues/2): antd Select cannot work when using Cron component 99 | - Add dependencies version in README 100 | - Add link in README to story for clear button action management 101 | 102 | #### 1.1.0 103 | 104 | - **(Breaking change!)** Drop support of antd version anterior to 4.6.0 due to 105 | change on rc-virtual-list 106 | - **(Breaking change!)** Fix period change not handling new value, changing 107 | period now change the value 108 | - Add the possibility to choose clear button action, empty or fill-with-every 109 | - Fix issue [#1](https://github.com/xrutayisire/react-js-cron/issues/1) Styling of popovers breaks with latest antd version 110 | - Fix a problem with the onBlur function not triggered by Select component 111 | - Fix antd automatic tree-shaking not working in many cases 112 | 113 | #### 1.0.8 114 | 115 | - Fix double-click wrong output 116 | - Rewrite the entire cron converter to support some missing cron expressions 117 | - Fix typo in peer dependencies 118 | - Improve read-only mode by hiding week days or month days if not needed 119 | 120 | #### 1.0.7 121 | 122 | - Improve rendering problems caused by the new responsive management 123 | - Fix locale update not changing some labels 124 | - Rename setError to onError to improve naming 125 | - Update README API section 126 | - Add @reboot to the supported shortcuts 127 | - Change leading zero prop type to be easy to use 128 | - Fix missing locale property for clear button text 129 | - Fix no-prefix class on period field 130 | - The default period should only be read once 131 | - Fix double margin-bottom on fields 132 | - Prevent select dropdown to overlap window by changing dropdown popup direction 133 | - Update demo links in README 134 | 135 | #### 1.0.6 136 | 137 | - Fix regression on multiple detection 138 | - Add a prop to choose a clock format, 12-hour clock or 24-hour clock 139 | - Fix bug with multiple on months and week days 140 | - Fix display of dropdown options when leadingZero is active 141 | - Update README to add features info and usage 142 | - Improve responsive design management 143 | - Add support for cron shortcuts by default 144 | - Update hooks dependencies to prevent multiple re-render 145 | - Support import with an alias 146 | 147 | #### 1.0.5 148 | 149 | - Update README image with new features 150 | - Add a prop leadingZero to add a '0' before number lower than 10 151 | - Add a prop to make the component read only 152 | - Add a prop to disable the component 153 | - Set day as default period 154 | - Add missing support for mixing week days with month and month days 155 | - Rename some locale properties to fix typos 156 | - Fix typo in jqCron name 157 | 158 | #### 1.0.4 159 | 160 | - Add a prop to humanize the value 161 | - The value should not be changed using humanizeLabels 162 | 163 | #### 1.0.3 164 | 165 | - Fix bug when the input string contains duplicates 166 | - Clear button should have a margin-bottom in case of a break line 167 | - Add prop to humanize labels for week days and months 168 | - Add a prop 'allowEmpty' to choose how the component handles the default value 169 | - Add a prop 'defaultPeriod' used when the default value is an empty string 170 | - Empty string and invalid string should not change the value 171 | - Fix a bug accepting string like '5-0' 172 | - Fix infinite loop for bad string like '\*/0' or '1-2/0' 173 | - Always use 0 for Sunday 174 | - Fix a bug when using 0 for Sunday 175 | 176 | #### 1.0.2 177 | 178 | - Fix bug that caused the impossibility to unselect last value 179 | - Style modification to display errors correctly over antd default style 180 | - Update peerDependencies to add minimal versions 181 | - Improve custom select onClickOption function 182 | - Fix typo in README 183 | 184 | #### 1.0.1 185 | 186 | - Update README image example for npm 187 | 188 | #### 1.0.0 189 | 190 | - Initial release of the library 191 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to ReactJS Cron 2 | 3 | Want to contribute to ReactJS Cron? There are a few things you need to know. 4 | 5 | ## Install 6 | 7 | This repository use yarn package manager, to install the dependencies run: 8 | 9 | `yarn` 10 | 11 | ## Storybook 12 | 13 | A storybook is available to check your changes. 14 | To dev with Storybook run: 15 | 16 | `yarn storybook` 17 | 18 | ## Build 19 | 20 | To check the package output run: 21 | 22 | `yarn build` 23 | 24 | ## Tests 25 | 26 | To check tests run: 27 | 28 | `yarn test` 29 | 30 | Add tests to cover new code and be sure that coverage didn't decrease with: 31 | 32 | `yarn test:coverage` 33 | 34 | ## Lint 35 | 36 | To check if your code don't have any lint or prettier problem run: 37 | 38 | `yarn format` 39 | 40 | To fix problems run: 41 | 42 | `yarn format:fix` 43 | 44 | ## Git commits 45 | 46 | This repository use [Commitizen](https://github.com/commitizen/cz-cli). 47 | When you commit with Commitizen, you'll be prompted to fill out any required commit fields at commit time. 48 | 49 | You need to run `git cz` and you'll be prompted to fill in any required fields, and your commit messages will be formatted according to the standards defined by react-js-cron. 50 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Xavier Rutayisire 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## ReactJS Cron 2 | 3 | > A React cron editor built with [antd](https://github.com/ant-design/ant-design) 4 | 5 | [![npm package](https://img.shields.io/npm/v/react-js-cron/latest.svg)](https://www.npmjs.com/package/react-js-cron) 6 | [![MIT License Badge](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/xrutayisire/react-js-cron/blob/master/LICENSE.md) 7 | 8 | [![Build](https://github.com/xrutayisire/react-js-cron/actions/workflows/build.yml/badge.svg)](https://github.com/xrutayisire/react-js-cron/actions/workflows/build.yml) 9 | [![Lint](https://github.com/xrutayisire/react-js-cron/actions/workflows/lint.yml/badge.svg)](https://github.com/xrutayisire/react-js-cron/actions/workflows/lint.yml) 10 | [![Unit tests](https://github.com/xrutayisire/react-js-cron/actions/workflows/test.yml/badge.svg)](https://github.com/xrutayisire/react-js-cron/actions/workflows/test.yml) 11 | [![codecov](https://codecov.io/gh/xrutayisire/react-js-cron/branch/master/graph/badge.svg?token=H4I8REN489)](https://codecov.io/gh/xrutayisire/react-js-cron) 12 | 13 | Live **demo** and **usage** at [https://xrutayisire.github.io/react-js-cron/](https://xrutayisire.github.io/react-js-cron/?path=/docs/reactjs-cron--demo) 14 | 15 | ![react-js-cron example](https://raw.githubusercontent.com/xrutayisire/react-js-cron/master/react-js-cron-example.png) 16 | 17 | ## Features 18 | 19 | - Zero dependencies except React and antd 20 | - Supports all standard cron expressions 21 | - Supports cron names for months and week days 22 | - Supports cron shortcuts 23 | - Supports "7" for Sunday 24 | - Supports two-way sync binding with input 25 | - Supports locale customization 26 | - Supports multiple selection by double-clicking on an option 27 | - And many more (disabled, read-only, 12-hour clock...) 28 | 29 | ## Inspired by 30 | 31 | - [jqCron](https://github.com/arnapou/jqcron) 32 | - [cron-converter](https://github.com/roccivic/cron-converter) 33 | 34 | ## TypeScript 35 | 36 | react-js-cron is written in TypeScript with complete definitions 37 | 38 | ## Installation 39 | 40 | Be sure that you have these dependencies on your project: 41 | 42 | - react (>=17.0.0) 43 | - antd (>=5.8.0) 44 | 45 | ```bash 46 | # NPM 47 | npm install react-js-cron 48 | 49 | # Yarn 50 | yarn add react-js-cron 51 | ``` 52 | 53 | ## Usage 54 | 55 | ```jsx 56 | import { Cron } from 'react-js-cron' 57 | import 'react-js-cron/dist/styles.css' 58 | 59 | export function App() { 60 | const [value, setValue] = useState('30 5 * * 1,6') 61 | 62 | return 63 | } 64 | ``` 65 | 66 | Don't forget to import styles manually: 67 | 68 | ```jsx 69 | import 'react-js-cron/dist/styles.css' 70 | ``` 71 | 72 | ## Converter 73 | 74 | If you want to use the converter that react-js-cron uses internally, you can import it into your project: 75 | 76 | ```jsx 77 | import { converter } from 'react-js-cron' 78 | 79 | const cronString = converter.getCronStringFromValues( 80 | 'day', // period: 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'reboot' 81 | [], // months: number[] | undefined 82 | [], // monthDays: number[] | undefined 83 | [], // weekDays: number[] | undefined 84 | [2], // hours: number[] | undefined 85 | [1], // minutes: number[] | undefined 86 | false // humanizeValue?: boolean 87 | ) 88 | 89 | console.log('cron string:', converted) 90 | ``` 91 | 92 | ``` 93 | cron string: '1 2 * * *' 94 | ``` 95 | 96 | The converter can also be helpful for parsing a string. Note that Sunday is represented as 0 in the output array but can be either 0 or 7 in the cron expression. 97 | 98 | ```jsx 99 | import { converter } from 'react-js-cron' 100 | 101 | const [minutes, hours, daysOfMonth, months, daysOfWeek] = 102 | converter.parseCronString('0 2,14 * * 1-5') 103 | 104 | console.log('parsed cron:', { 105 | minutes, 106 | hours, 107 | daysOfMonth, 108 | months, 109 | daysOfWeek, 110 | }) 111 | ``` 112 | 113 | ``` 114 | parsed cron: { 115 | minutes: [0], 116 | hours: [2, 14], 117 | daysOfMonth: [], 118 | months: [], 119 | daysOfWeek: [1, 2, 3, 4, 5] 120 | } 121 | ``` 122 | 123 | ## Examples 124 | 125 | Learn more with [dynamic settings](https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--dynamic-settings). 126 | 127 | - [Two-way sync binding with input](https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--input) 128 | - [Default value](https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--default-value) 129 | - [Default period](https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--default-period) 130 | - [Disabled mode](https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--disabled) 131 | - [Read-Only mode](https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--read-only) 132 | - [Humanized labels](https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--humanize-labels) 133 | - [Humanized value](https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--humanize-value) 134 | - [Leading zero for numbers](https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--leading-zero) 135 | - [Error management with text and style](https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--track-error) 136 | - ["Clear button" removal](https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--no-clear-button) 137 | - ["Clear button" action](https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--clear-button-empty-value) 138 | - [Empty value management](https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--empty-never-allowed) 139 | - [Cron shortcuts](https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--shortcuts) 140 | - [12-hour clock](https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--twelve-hour-clock) 141 | - [24-hour clock](https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--twenty-four-hour-clock) 142 | - [Locale customization](https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--french-locale) 143 | - [Prefix and suffix removal](https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--no-prefix-and-suffix) 144 | - [Style customization](https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--custom-style) 145 | - And many more... 146 | 147 | ## API 148 | 149 | ``` 150 | CronProps { 151 | /** 152 | * Cron value, the component is by design a controlled component. 153 | * The first value will be the default value. 154 | * 155 | * required 156 | */ 157 | value: string 158 | 159 | /** 160 | * Set the cron value, similar to onChange. 161 | * The naming tells you that you have to set the value by yourself. 162 | * 163 | * required 164 | */ 165 | setValue: 166 | | (value: string, extra: { selectedPeriod }) => void 167 | | Dispatch> 168 | 169 | /** 170 | * Set the container className and used as a prefix for other selectors. 171 | * Available selectors: https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--custom-style 172 | */ 173 | className?: string 174 | 175 | /** 176 | * Humanize the labels in the cron component, SUN-SAT and JAN-DEC. 177 | * 178 | * Default: true 179 | */ 180 | humanizeLabels?: boolean 181 | 182 | /** 183 | * Humanize the value, SUN-SAT and JAN-DEC. 184 | * 185 | * Default: false 186 | */ 187 | humanizeValue?: boolean 188 | 189 | /** 190 | * Add a "0" before numbers lower than 10. 191 | * 192 | * Default: false 193 | */ 194 | leadingZero?: boolean | ['month-days', 'hours', 'minutes'] 195 | 196 | /** 197 | * Define the default period when the default value is empty. 198 | * 199 | * Default: 'day' 200 | */ 201 | defaultPeriod?: 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'reboot' 202 | 203 | /** 204 | * Disable the cron component. 205 | * 206 | * Default: false 207 | */ 208 | disabled?: boolean 209 | 210 | /** 211 | * Make the cron component read-only. 212 | * 213 | * Default: false 214 | */ 215 | readOnly?: boolean 216 | 217 | /** 218 | * Show clear button for each dropdown. 219 | * 220 | * Default: true 221 | */ 222 | allowClear?: boolean 223 | 224 | /** 225 | * Define if empty should trigger an error. 226 | * 227 | * Default: 'for-default-value' 228 | */ 229 | allowEmpty?: 'always' | 'never' | 'for-default-value' 230 | 231 | /** 232 | * Support cron shortcuts. 233 | * 234 | * Default: ['@yearly', '@annually', '@monthly', '@weekly', '@daily', '@midnight', '@hourly'] 235 | */ 236 | shortcuts?: boolean | ['@yearly', '@annually', '@monthly', '@weekly', '@daily', '@midnight', '@hourly', '@reboot'] 237 | 238 | /** 239 | * Define the clock format. 240 | * 241 | * Default: undefined 242 | */ 243 | clockFormat?: '12-hour-clock' | '24-hour-clock' 244 | 245 | /** 246 | * Display the clear button. 247 | * 248 | * Default: true 249 | */ 250 | clearButton?: boolean 251 | 252 | /** 253 | * antd button props to customize the clear button. 254 | */ 255 | clearButtonProps?: ButtonProps 256 | 257 | /** 258 | * Define the clear button action. 259 | * 260 | * Default: 'fill-with-every' 261 | */ 262 | clearButtonAction?: 'empty' | 'fill-with-every' 263 | 264 | /** 265 | * Display error style (red border and background). 266 | * 267 | * Display: true 268 | */ 269 | displayError?: boolean 270 | 271 | /** 272 | * Triggered when the cron component detects an error with the value. 273 | */ 274 | onError?: 275 | | (error: { 276 | type: 'invalid_cron' 277 | description: string 278 | }) => void 279 | | Dispatch> 283 | | undefined 284 | 285 | /** 286 | * Define if a double click on a dropdown option should automatically 287 | * select / unselect a periodicity. 288 | * 289 | * Default: true 290 | */ 291 | periodicityOnDoubleClick?: boolean 292 | 293 | /** 294 | * Define if it's possible to select only one or multiple values for each dropdown. 295 | * 296 | * Even in single mode, if you want to disable the double click on a dropdown option that 297 | * automatically select / unselect a periodicity, set 'periodicityOnDoubleClick' 298 | * prop at false. 299 | * 300 | * When single mode is active and 'periodicityOnDoubleClick' is false, 301 | * each dropdown will automatically close after selecting a value 302 | * 303 | * Default: 'multiple' 304 | */ 305 | mode?: 'multiple' | 'single' 306 | 307 | /** 308 | * Define which dropdowns need to be displayed. 309 | * 310 | * Default: ['period', 'months', 'month-days', 'week-days', 'hours', 'minutes'] 311 | */ 312 | allowedDropdowns?: [ 313 | 'period', 314 | 'months', 315 | 'month-days', 316 | 'week-days', 317 | 'hours', 318 | 'minutes' 319 | ] 320 | 321 | /** 322 | * Define the list of periods available. 323 | * 324 | * Default: ['year', 'month', 'week', 'day', 'hour', 'minute', 'reboot'] 325 | */ 326 | allowedPeriods?: ['year', 'month', 'week', 'day', 'hour', 'minute', 'reboot'] 327 | 328 | /** 329 | * Define a configuration that is used for each dropdown specifically. 330 | * Configuring a dropdown will override any global configuration for the same property. 331 | * 332 | * Configurations available: 333 | * 334 | * // See global configuration 335 | * // For 'months' and 'week-days' 336 | * humanizeLabels?: boolean 337 | * 338 | * // See global configuration 339 | * // For 'months' and 'week-days' 340 | * humanizeValue?: boolean 341 | * 342 | * // See global configuration 343 | * // For 'month-days', 'hours' and 'minutes' 344 | * leadingZero?: boolean 345 | * 346 | * // See global configuration 347 | * For 'period', 'months', 'month-days', 'week-days', 'hours' and 'minutes' 348 | * disabled?: boolean 349 | * 350 | * // See global configuration 351 | * For 'period', 'months', 'month-days', 'week-days', 'hours' and 'minutes' 352 | * readOnly?: boolean 353 | * 354 | * // See global configuration 355 | * // For 'period', 'months', 'month-days', 'week-days', 'hours' and 'minutes' 356 | * allowClear?: boolean 357 | * 358 | * // See global configuration 359 | * // For 'months', 'month-days', 'week-days', 'hours' and 'minutes' 360 | * periodicityOnDoubleClick?: boolean 361 | * 362 | * // See global configuration 363 | * // For 'months', 'month-days', 'week-days', 'hours' and 'minutes' 364 | * mode?: Mode 365 | * 366 | * // The function will receive one argument, an object with value and label. 367 | * // If the function returns true, the option will be included in the filtered set. 368 | * // Otherwise, it will be excluded. 369 | * // For 'months', 'month-days', 'week-days', 'hours' and 'minutes' 370 | * filterOption?: FilterOption 371 | * 372 | * Default: undefined 373 | */ 374 | dropdownsConfig?: { 375 | 'period'?: { 376 | disabled?: boolean 377 | readOnly?: boolean 378 | allowClear?: boolean 379 | } 380 | 'months'?: { 381 | humanizeLabels?: boolean 382 | humanizeValue?: boolean 383 | disabled?: boolean 384 | readOnly?: boolean 385 | allowClear?: boolean 386 | periodicityOnDoubleClick?: boolean 387 | mode?: 'multiple' | 'single' 388 | filterOption?: ({ 389 | value, 390 | label, 391 | }: { 392 | value: string 393 | label: string 394 | }) => boolean 395 | } 396 | 'month-days'?: { 397 | leadingZero?: boolean 398 | disabled?: boolean 399 | readOnly?: boolean 400 | allowClear?: boolean 401 | periodicityOnDoubleClick?: boolean 402 | mode?: 'multiple' | 'single' 403 | filterOption?: ({ 404 | value, 405 | label, 406 | }: { 407 | value: string 408 | label: string 409 | }) => boolean 410 | } 411 | 'week-days'?: { 412 | humanizeLabels?: boolean 413 | humanizeValue?: boolean 414 | disabled?: boolean 415 | readOnly?: boolean 416 | allowClear?: boolean 417 | periodicityOnDoubleClick?: boolean 418 | mode?: 'multiple' | 'single' 419 | filterOption?: ({ 420 | value, 421 | label, 422 | }: { 423 | value: string 424 | label: string 425 | }) => boolean 426 | } 427 | 'hours'?: { 428 | leadingZero?: boolean 429 | disabled?: boolean 430 | readOnly?: boolean 431 | allowClear?: boolean 432 | periodicityOnDoubleClick?: boolean 433 | mode?: 'multiple' | 'single' 434 | filterOption?: ({ 435 | value, 436 | label, 437 | }: { 438 | value: string 439 | label: string 440 | }) => boolean 441 | } 442 | 'minutes'?: { 443 | leadingZero?: boolean 444 | disabled?: boolean 445 | readOnly?: boolean 446 | allowClear?: boolean 447 | periodicityOnDoubleClick?: boolean 448 | mode?: 'multiple' | 'single' 449 | filterOption?: ({ 450 | value, 451 | label, 452 | }: { 453 | value: string 454 | label: string 455 | }) => boolean 456 | } 457 | } 458 | 459 | /** 460 | * Change the component language. 461 | * Can also be used to remove prefix and suffix. 462 | * 463 | * When setting 'humanizeLabels' you can change the language of the 464 | * alternative labels with 'altWeekDays' and 'altMonths'. 465 | * 466 | * The order of the 'locale' properties 'weekDays', 'months', 'altMonths' 467 | * and 'altWeekDays' is important! The index will be used as value. 468 | * 469 | * Default './src/locale.ts' 470 | */ 471 | locale?: { 472 | everyText?: string 473 | emptyMonths?: string 474 | emptyMonthDays?: string 475 | emptyMonthDaysShort?: string 476 | emptyWeekDays?: string 477 | emptyWeekDaysShort?: string 478 | emptyHours?: string 479 | emptyMinutes?: string 480 | emptyMinutesForHourPeriod?: string 481 | yearOption?: string 482 | monthOption?: string 483 | weekOption?: string 484 | dayOption?: string 485 | hourOption?: string 486 | minuteOption?: string 487 | rebootOption?: string 488 | prefixPeriod?: string 489 | prefixMonths?: string 490 | prefixMonthDays?: string 491 | prefixWeekDays?: string 492 | prefixWeekDaysForMonthAndYearPeriod?: string 493 | prefixHours?: string 494 | prefixMinutes?: string 495 | prefixMinutesForHourPeriod?: string 496 | suffixMinutesForHourPeriod?: string 497 | errorInvalidCron?: string 498 | weekDays?: string[] 499 | months?: string[] 500 | altWeekDays?: string[] 501 | altMonths?: string[] 502 | } 503 | 504 | /** 505 | * Define the container for the dropdowns. 506 | * By default, the dropdowns will be rendered in the body. 507 | * This is useful when you want to render the dropdowns in a specific 508 | * container, for example, when using a modal or a specific layout. 509 | */ 510 | getPopupContainer?: () => HTMLElement 511 | } 512 | ``` 513 | 514 | ## License 515 | 516 | MIT © [xrutayisire](https://github.com/xrutayisire) 517 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-js-cron", 3 | "version": "5.2.0", 4 | "description": "A React cron editor with antd inspired by jqCron", 5 | "author": "Xavier Rutayisire (https://github.com/xrutayisire/)", 6 | "license": "MIT", 7 | "keywords": [ 8 | "react", 9 | "reactjs", 10 | "js", 11 | "cron", 12 | "crontab", 13 | "editor", 14 | "widget", 15 | "generator", 16 | "antd", 17 | "ant-design", 18 | "cronjob", 19 | "schedule", 20 | "parser" 21 | ], 22 | "homepage": "https://github.com/xrutayisire/react-js-cron#readme", 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/xrutayisire/react-js-cron.git" 26 | }, 27 | "bugs": { 28 | "url": "https://github.com/xrutayisire/react-js-cron/issues" 29 | }, 30 | "main": "dist/cjs/index.js", 31 | "module": "dist/esm/index.js", 32 | "types": "dist/index.d.ts", 33 | "files": [ 34 | "dist/", 35 | "package.json", 36 | "README.md", 37 | "LICENSE.md" 38 | ], 39 | "packageManager": "yarn@3.3.0", 40 | "scripts": { 41 | "build": "run-s build:del build:rollup", 42 | "build:rollup": "rollup -c", 43 | "build:del": "del dist", 44 | "dev": "rollup -c -w", 45 | "storybook": "start-storybook -p 9009", 46 | "build-storybook": "build-storybook -o docs", 47 | "lint": "eslint 'src/**/*.ts?(x)'", 48 | "lint:fix": "yarn lint --fix", 49 | "prettier": "prettier --check 'src/**/*.{ts?(x),css}'", 50 | "prettier:fix": "yarn prettier --write", 51 | "format": "run-s lint prettier", 52 | "format:fix": "run-s lint:fix prettier:fix", 53 | "test": "react-scripts test", 54 | "test:coverage": "yarn test --coverage --watchAll" 55 | }, 56 | "config": { 57 | "commitizen": { 58 | "path": "./node_modules/cz-conventional-changelog" 59 | } 60 | }, 61 | "resolutions": { 62 | "@ant-design/cssinjs": "1.23.0", 63 | "enhanced-resolve": "5.10.0" 64 | }, 65 | "peerDependencies": { 66 | "antd": ">=5.8.0", 67 | "react": ">=17.0.0", 68 | "react-dom": ">=17.0.0" 69 | }, 70 | "devDependencies": { 71 | "@ant-design/icons": "6.0.0", 72 | "@babel/core": "7.20.5", 73 | "@rollup/plugin-commonjs": "23.0.3", 74 | "@rollup/plugin-node-resolve": "15.0.1", 75 | "@rollup/plugin-terser": "0.1.0", 76 | "@rollup/plugin-typescript": "10.0.1", 77 | "@storybook/addon-actions": "6.5.13", 78 | "@storybook/addon-docs": "6.5.13", 79 | "@storybook/addon-essentials": "6.5.13", 80 | "@storybook/addon-interactions": "6.5.13", 81 | "@storybook/addon-links": "6.5.13", 82 | "@storybook/builder-webpack5": "6.5.13", 83 | "@storybook/manager-webpack5": "6.5.13", 84 | "@storybook/react": "6.5.13", 85 | "@storybook/testing-library": "0.0.13", 86 | "@testing-library/jest-dom": "5.16.5", 87 | "@testing-library/react": "13.4.0", 88 | "@testing-library/react-hooks": "8.0.1", 89 | "@testing-library/user-event": "14.4.3", 90 | "@trivago/prettier-plugin-sort-imports": "3.4.0", 91 | "@types/node": "18.11.9", 92 | "@types/react": "18.0.25", 93 | "@types/react-dom": "18.0.9", 94 | "@typescript-eslint/eslint-plugin": "5.44.0", 95 | "@typescript-eslint/parser": "5.44.0", 96 | "antd": "5.24.7", 97 | "babel-loader": "8.3.0", 98 | "cz-conventional-changelog": "3.3.0", 99 | "del-cli": "5.0.0", 100 | "eslint": "8.28.0", 101 | "eslint-config-prettier": "8.5.0", 102 | "eslint-plugin-file-progress": "1.3.0", 103 | "eslint-plugin-react": "7.31.11", 104 | "eslint-plugin-react-hooks": "4.6.0", 105 | "npm-run-all": "4.1.5", 106 | "prettier": "2.8.0", 107 | "react": "18.2.0", 108 | "react-dom": "18.2.0", 109 | "react-scripts": "5.0.1", 110 | "rollup": "3.5.0", 111 | "rollup-plugin-copy": "3.4.0", 112 | "rollup-plugin-dts": "5.0.0", 113 | "rollup-plugin-peer-deps-external": "2.2.4", 114 | "typescript": "4.9.3" 115 | }, 116 | "jest": { 117 | "collectCoverageFrom": [ 118 | "src/**", 119 | "!src/types.ts", 120 | "!src/index.ts", 121 | "!src/stories/**" 122 | ], 123 | "coverageReporters": [ 124 | "lcov", 125 | "json-summary" 126 | ] 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /react-js-cron-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xrutayisire/react-js-cron/dc70d15eb6a01ca34514cf0a5d4c0649d9eba344/react-js-cron-example.png -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import terser from '@rollup/plugin-terser' 4 | import typescript from '@rollup/plugin-typescript' 5 | import copy from 'rollup-plugin-copy' 6 | import dts from 'rollup-plugin-dts' 7 | import external from 'rollup-plugin-peer-deps-external' 8 | 9 | export default [ 10 | { 11 | input: 'src/index.ts', 12 | output: [ 13 | { 14 | file: 'dist/cjs/index.js', 15 | format: 'cjs', 16 | assetFileNames: '[name][extname]', 17 | }, 18 | { 19 | file: 'dist/esm/index.js', 20 | format: 'esm', 21 | assetFileNames: '[name][extname]', 22 | }, 23 | ], 24 | plugins: [ 25 | external(), 26 | resolve(), 27 | commonjs(), 28 | typescript({ 29 | exclude: ['**/*.stories.*', '**/*.test.*'], 30 | }), 31 | terser(), 32 | copy({ 33 | targets: [{ src: 'src/styles.css', dest: 'dist' }], 34 | }), 35 | ], 36 | }, 37 | { 38 | input: 'dist/esm/types/index.d.ts', 39 | output: [{ file: 'dist/index.d.ts', format: 'esm' }], 40 | plugins: [dts()], 41 | }, 42 | ] 43 | -------------------------------------------------------------------------------- /src/Cron.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from 'antd' 2 | import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 3 | 4 | import { getCronStringFromValues, setValuesFromCronString } from './converter' 5 | import Hours from './fields/Hours' 6 | import Minutes from './fields/Minutes' 7 | import MonthDays from './fields/MonthDays' 8 | import Months from './fields/Months' 9 | import Period from './fields/Period' 10 | import WeekDays from './fields/WeekDays' 11 | import { DEFAULT_LOCALE_EN } from './locale' 12 | import { CronProps, PeriodType } from './types' 13 | import { classNames, setError, usePrevious } from './utils' 14 | 15 | export default function Cron(props: CronProps) { 16 | const { 17 | clearButton = true, 18 | clearButtonProps = {}, 19 | clearButtonAction = 'fill-with-every', 20 | locale = DEFAULT_LOCALE_EN, 21 | value = '', 22 | setValue, 23 | displayError = true, 24 | onError, 25 | className, 26 | defaultPeriod = 'day', 27 | allowEmpty = 'for-default-value', 28 | humanizeLabels = true, 29 | humanizeValue = false, 30 | disabled = false, 31 | readOnly = false, 32 | leadingZero = false, 33 | shortcuts = [ 34 | '@yearly', 35 | '@annually', 36 | '@monthly', 37 | '@weekly', 38 | '@daily', 39 | '@midnight', 40 | '@hourly', 41 | ], 42 | clockFormat, 43 | periodicityOnDoubleClick = true, 44 | mode = 'multiple', 45 | allowedDropdowns = [ 46 | 'period', 47 | 'months', 48 | 'month-days', 49 | 'week-days', 50 | 'hours', 51 | 'minutes', 52 | ], 53 | allowedPeriods = [ 54 | 'year', 55 | 'month', 56 | 'week', 57 | 'day', 58 | 'hour', 59 | 'minute', 60 | 'reboot', 61 | ], 62 | allowClear, 63 | dropdownsConfig, 64 | getPopupContainer, 65 | } = props 66 | const internalValueRef = useRef(value) 67 | const defaultPeriodRef = useRef(defaultPeriod) 68 | const [period, setPeriod] = useState() 69 | const [monthDays, setMonthDays] = useState() 70 | const [months, setMonths] = useState() 71 | const [weekDays, setWeekDays] = useState() 72 | const [hours, setHours] = useState() 73 | const [minutes, setMinutes] = useState() 74 | const [error, setInternalError] = useState(false) 75 | const [valueCleared, setValueCleared] = useState(false) 76 | const previousValueCleared = usePrevious(valueCleared) 77 | const localeJSON = JSON.stringify(locale) 78 | 79 | useEffect( 80 | () => { 81 | setValuesFromCronString( 82 | value, 83 | setInternalError, 84 | onError, 85 | allowEmpty, 86 | internalValueRef, 87 | true, 88 | locale, 89 | shortcuts, 90 | setMinutes, 91 | setHours, 92 | setMonthDays, 93 | setMonths, 94 | setWeekDays, 95 | setPeriod 96 | ) 97 | }, 98 | // eslint-disable-next-line react-hooks/exhaustive-deps 99 | [] 100 | ) 101 | 102 | useEffect( 103 | () => { 104 | if (value !== internalValueRef.current) { 105 | setValuesFromCronString( 106 | value, 107 | setInternalError, 108 | onError, 109 | allowEmpty, 110 | internalValueRef, 111 | false, 112 | locale, 113 | shortcuts, 114 | setMinutes, 115 | setHours, 116 | setMonthDays, 117 | setMonths, 118 | setWeekDays, 119 | setPeriod 120 | ) 121 | } 122 | }, 123 | // eslint-disable-next-line react-hooks/exhaustive-deps 124 | [value, internalValueRef, localeJSON, allowEmpty, shortcuts] 125 | ) 126 | 127 | useEffect( 128 | () => { 129 | // Only change the value if a user touched a field 130 | // and if the user didn't use the clear button 131 | if ( 132 | (period || minutes || months || monthDays || weekDays || hours) && 133 | !valueCleared && 134 | !previousValueCleared 135 | ) { 136 | const selectedPeriod = period || defaultPeriodRef.current 137 | const cron = getCronStringFromValues( 138 | selectedPeriod, 139 | months, 140 | monthDays, 141 | weekDays, 142 | hours, 143 | minutes, 144 | humanizeValue, 145 | dropdownsConfig 146 | ) 147 | 148 | setValue(cron, { selectedPeriod }) 149 | internalValueRef.current = cron 150 | 151 | onError && onError(undefined) 152 | setInternalError(false) 153 | } else if (valueCleared) { 154 | setValueCleared(false) 155 | } 156 | }, 157 | // eslint-disable-next-line react-hooks/exhaustive-deps 158 | [ 159 | period, 160 | monthDays, 161 | months, 162 | weekDays, 163 | hours, 164 | minutes, 165 | humanizeValue, 166 | valueCleared, 167 | dropdownsConfig, 168 | ] 169 | ) 170 | 171 | const handleClear = useCallback( 172 | () => { 173 | setMonthDays(undefined) 174 | setMonths(undefined) 175 | setWeekDays(undefined) 176 | setHours(undefined) 177 | setMinutes(undefined) 178 | 179 | // When clearButtonAction is 'empty' 180 | let newValue = '' 181 | 182 | const newPeriod = 183 | period !== 'reboot' && period ? period : defaultPeriodRef.current 184 | 185 | if (newPeriod !== period) { 186 | setPeriod(newPeriod) 187 | } 188 | 189 | // When clearButtonAction is 'fill-with-every' 190 | if (clearButtonAction === 'fill-with-every') { 191 | const cron = getCronStringFromValues( 192 | newPeriod, 193 | undefined, 194 | undefined, 195 | undefined, 196 | undefined, 197 | undefined, 198 | undefined, 199 | undefined 200 | ) 201 | 202 | newValue = cron 203 | } 204 | 205 | setValue(newValue, { selectedPeriod: newPeriod }) 206 | internalValueRef.current = newValue 207 | 208 | setValueCleared(true) 209 | 210 | if (allowEmpty === 'never' && clearButtonAction === 'empty') { 211 | setInternalError(true) 212 | setError(onError, locale) 213 | } else { 214 | onError && onError(undefined) 215 | setInternalError(false) 216 | } 217 | }, 218 | // eslint-disable-next-line react-hooks/exhaustive-deps 219 | [period, setValue, onError, clearButtonAction] 220 | ) 221 | 222 | const internalClassName = useMemo( 223 | () => 224 | classNames({ 225 | 'react-js-cron': true, 226 | 'react-js-cron-error': error && displayError, 227 | 'react-js-cron-disabled': disabled, 228 | 'react-js-cron-read-only': readOnly, 229 | [`${className}`]: !!className, 230 | [`${className}-error`]: error && displayError && !!className, 231 | [`${className}-disabled`]: disabled && !!className, 232 | [`${className}-read-only`]: readOnly && !!className, 233 | }), 234 | [className, error, displayError, disabled, readOnly] 235 | ) 236 | 237 | const { className: clearButtonClassNameProp, ...otherClearButtonProps } = 238 | clearButtonProps 239 | const clearButtonClassName = useMemo( 240 | () => 241 | classNames({ 242 | 'react-js-cron-clear-button': true, 243 | [`${className}-clear-button`]: !!className, 244 | [`${clearButtonClassNameProp}`]: !!clearButtonClassNameProp, 245 | }), 246 | [className, clearButtonClassNameProp] 247 | ) 248 | 249 | const otherClearButtonPropsJSON = JSON.stringify(otherClearButtonProps) 250 | const clearButtonNode = useMemo( 251 | () => { 252 | if (clearButton && !readOnly) { 253 | return ( 254 | 264 | ) 265 | } 266 | 267 | return null 268 | }, 269 | // eslint-disable-next-line react-hooks/exhaustive-deps 270 | [ 271 | clearButton, 272 | readOnly, 273 | localeJSON, 274 | clearButtonClassName, 275 | disabled, 276 | otherClearButtonPropsJSON, 277 | handleClear, 278 | ] 279 | ) 280 | 281 | const periodForRender = period || defaultPeriodRef.current 282 | 283 | return ( 284 |
285 | {allowedDropdowns.includes('period') && ( 286 | 298 | )} 299 | 300 | {periodForRender === 'reboot' ? ( 301 | clearButtonNode 302 | ) : ( 303 | <> 304 | {periodForRender === 'year' && 305 | allowedDropdowns.includes('months') && ( 306 | 326 | )} 327 | 328 | {(periodForRender === 'year' || periodForRender === 'month') && 329 | allowedDropdowns.includes('month-days') && ( 330 | 353 | )} 354 | 355 | {(periodForRender === 'year' || 356 | periodForRender === 'month' || 357 | periodForRender === 'week') && 358 | allowedDropdowns.includes('week-days') && ( 359 | 383 | )} 384 | 385 |
386 | {periodForRender !== 'minute' && 387 | periodForRender !== 'hour' && 388 | allowedDropdowns.includes('hours') && ( 389 | 410 | )} 411 | 412 | {periodForRender !== 'minute' && 413 | allowedDropdowns.includes('minutes') && ( 414 | 437 | )} 438 | 439 | {clearButtonNode} 440 |
441 | 442 | )} 443 |
444 | ) 445 | } 446 | -------------------------------------------------------------------------------- /src/components/CustomSelect.tsx: -------------------------------------------------------------------------------- 1 | import { Select } from 'antd' 2 | import React, { useCallback, useMemo, useRef } from 'react' 3 | 4 | import { formatValue, parsePartArray, partToString } from '../converter' 5 | import { DEFAULT_LOCALE_EN } from '../locale' 6 | import { Clicks, CustomSelectProps } from '../types' 7 | import { classNames, sort } from '../utils' 8 | 9 | export default function CustomSelect(props: CustomSelectProps) { 10 | const { 11 | value, 12 | grid = true, 13 | optionsList, 14 | setValue, 15 | locale, 16 | className, 17 | humanizeLabels, 18 | disabled, 19 | readOnly, 20 | leadingZero, 21 | clockFormat, 22 | period, 23 | unit, 24 | periodicityOnDoubleClick, 25 | mode, 26 | allowClear, 27 | filterOption = () => true, 28 | getPopupContainer, 29 | ...otherProps 30 | } = props 31 | 32 | const stringValue = useMemo(() => { 33 | if (value && Array.isArray(value)) { 34 | return value.map((value: number) => value.toString()) 35 | } 36 | }, [value]) 37 | 38 | const options = useMemo( 39 | () => { 40 | if (optionsList) { 41 | return optionsList 42 | .map((option, index) => { 43 | const number = unit.min === 0 ? index : index + 1 44 | 45 | return { 46 | value: number.toString(), 47 | label: option, 48 | } 49 | }) 50 | .filter(filterOption) 51 | } 52 | 53 | return [...Array(unit.total)] 54 | .map((e, index) => { 55 | const number = unit.min === 0 ? index : index + 1 56 | 57 | return { 58 | value: number.toString(), 59 | label: formatValue( 60 | number, 61 | unit, 62 | humanizeLabels, 63 | leadingZero, 64 | clockFormat 65 | ), 66 | } 67 | }) 68 | .filter(filterOption) 69 | }, 70 | // eslint-disable-next-line react-hooks/exhaustive-deps 71 | [optionsList, leadingZero, humanizeLabels, clockFormat] 72 | ) 73 | const localeJSON = JSON.stringify(locale) 74 | const renderTag = useCallback( 75 | (props: { value: string[] | undefined }) => { 76 | const { value: itemValue } = props 77 | 78 | if (!value || value[0] !== Number(itemValue)) { 79 | return <> 80 | } 81 | 82 | const parsedArray = parsePartArray(value, unit) 83 | const cronValue = partToString( 84 | parsedArray, 85 | unit, 86 | humanizeLabels, 87 | leadingZero, 88 | clockFormat 89 | ) 90 | const testEveryValue = cronValue.match(/^\*\/([0-9]+),?/) || [] 91 | 92 | return ( 93 |
94 | {testEveryValue[1] 95 | ? `${locale.everyText || DEFAULT_LOCALE_EN.everyText} ${ 96 | testEveryValue[1] 97 | }` 98 | : cronValue} 99 |
100 | ) 101 | }, 102 | // eslint-disable-next-line react-hooks/exhaustive-deps 103 | [value, localeJSON, humanizeLabels, leadingZero, clockFormat] 104 | ) 105 | 106 | const simpleClick = useCallback( 107 | (newValueOption: number | number[]) => { 108 | const newValueOptions = Array.isArray(newValueOption) 109 | ? sort(newValueOption) 110 | : [newValueOption] 111 | let newValue: number[] = newValueOptions 112 | 113 | if (value) { 114 | newValue = mode === 'single' ? [] : [...value] 115 | 116 | newValueOptions.forEach((o) => { 117 | const newValueOptionNumber = Number(o) 118 | 119 | if (value.some((v) => v === newValueOptionNumber)) { 120 | newValue = newValue.filter((v) => v !== newValueOptionNumber) 121 | } else { 122 | newValue = sort([...newValue, newValueOptionNumber]) 123 | } 124 | }) 125 | } 126 | 127 | if (newValue.length === unit.total) { 128 | setValue([]) 129 | } else { 130 | setValue(newValue) 131 | } 132 | }, 133 | // eslint-disable-next-line react-hooks/exhaustive-deps 134 | [setValue, value] 135 | ) 136 | 137 | const doubleClick = useCallback( 138 | (newValueOption: number) => { 139 | if (newValueOption !== 0 && newValueOption !== 1) { 140 | const limit = unit.total + unit.min 141 | const newValue: number[] = [] 142 | 143 | for (let i = unit.min; i < limit; i++) { 144 | if (i % newValueOption === 0) { 145 | newValue.push(i) 146 | } 147 | } 148 | const oldValueEqualNewValue = 149 | value && 150 | newValue && 151 | value.length === newValue.length && 152 | value.every((v: number, i: number) => v === newValue[i]) 153 | const allValuesSelected = newValue.length === options.length 154 | 155 | if (allValuesSelected) { 156 | setValue([]) 157 | } else if (oldValueEqualNewValue) { 158 | setValue([]) 159 | } else { 160 | setValue(newValue) 161 | } 162 | } else { 163 | setValue([]) 164 | } 165 | }, 166 | // eslint-disable-next-line react-hooks/exhaustive-deps 167 | [value, options, setValue] 168 | ) 169 | 170 | const clicksRef = useRef([]) 171 | const onOptionClick = useCallback( 172 | (newValueOption: string) => { 173 | if (!readOnly) { 174 | const doubleClickTimeout = 300 175 | const clicks = clicksRef.current 176 | 177 | clicks.push({ 178 | time: new Date().getTime(), 179 | value: Number(newValueOption), 180 | }) 181 | 182 | const id = window.setTimeout(() => { 183 | if ( 184 | periodicityOnDoubleClick && 185 | clicks.length > 1 && 186 | clicks[clicks.length - 1].time - clicks[clicks.length - 2].time < 187 | doubleClickTimeout 188 | ) { 189 | if ( 190 | clicks[clicks.length - 1].value === 191 | clicks[clicks.length - 2].value 192 | ) { 193 | doubleClick(Number(newValueOption)) 194 | } else { 195 | simpleClick([ 196 | clicks[clicks.length - 2].value, 197 | clicks[clicks.length - 1].value, 198 | ]) 199 | } 200 | } else { 201 | simpleClick(Number(newValueOption)) 202 | } 203 | 204 | clicksRef.current = [] 205 | }, doubleClickTimeout) 206 | 207 | return () => { 208 | window.clearTimeout(id) 209 | } 210 | } 211 | }, 212 | [clicksRef, simpleClick, doubleClick, readOnly, periodicityOnDoubleClick] 213 | ) 214 | 215 | // Used by the select clear icon 216 | const onClear = useCallback(() => { 217 | if (!readOnly) { 218 | setValue([]) 219 | } 220 | }, [setValue, readOnly]) 221 | 222 | const internalClassName = useMemo( 223 | () => 224 | classNames({ 225 | 'react-js-cron-select': true, 226 | 'react-js-cron-custom-select': true, 227 | [`${className}-select`]: !!className, 228 | }), 229 | [className] 230 | ) 231 | 232 | const popupClassName = useMemo( 233 | () => 234 | classNames({ 235 | 'react-js-cron-select-dropdown': true, 236 | [`react-js-cron-select-dropdown-${unit.type}`]: true, 237 | 'react-js-cron-custom-select-dropdown': true, 238 | [`react-js-cron-custom-select-dropdown-${unit.type}`]: true, 239 | [`react-js-cron-custom-select-dropdown-minutes-large`]: 240 | unit.type === 'minutes' && period !== 'hour' && period !== 'day', 241 | [`react-js-cron-custom-select-dropdown-minutes-medium`]: 242 | unit.type === 'minutes' && (period === 'day' || period === 'hour'), 243 | 'react-js-cron-custom-select-dropdown-hours-twelve-hour-clock': 244 | unit.type === 'hours' && clockFormat === '12-hour-clock', 245 | 'react-js-cron-custom-select-dropdown-grid': !!grid, 246 | [`${className}-select-dropdown`]: !!className, 247 | [`${className}-select-dropdown-${unit.type}`]: !!className, 248 | }), 249 | // eslint-disable-next-line react-hooks/exhaustive-deps 250 | [className, grid, clockFormat, period] 251 | ) 252 | 253 | return ( 254 | 255 | // Use 'multiple' instead of 'tags‘ mode 256 | // cf: Issue #2 257 | mode={ 258 | mode === 'single' && !periodicityOnDoubleClick ? undefined : 'multiple' 259 | } 260 | allowClear={allowClear ?? !readOnly} 261 | virtual={false} 262 | open={readOnly ? false : undefined} 263 | value={stringValue} 264 | onClear={onClear} 265 | tagRender={renderTag} 266 | className={internalClassName} 267 | popupClassName={popupClassName} 268 | options={options} 269 | showSearch={false} 270 | suffixIcon={readOnly ? null : undefined} 271 | menuItemSelectedIcon={null} 272 | popupMatchSelectWidth={false} 273 | onSelect={onOptionClick} 274 | onDeselect={onOptionClick} 275 | disabled={disabled} 276 | dropdownAlign={ 277 | (unit.type === 'minutes' || unit.type === 'hours') && 278 | period !== 'day' && 279 | period !== 'hour' 280 | ? { 281 | // Usage: https://github.com/yiminghe/dom-align 282 | // Set direction to left to prevent dropdown to overlap window 283 | points: ['tr', 'br'], 284 | } 285 | : undefined 286 | } 287 | data-testid={`custom-select-${unit.type}`} 288 | getPopupContainer={getPopupContainer} 289 | {...otherProps} 290 | /> 291 | ) 292 | } 293 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { ShortcutsValues, Unit } from './types' 2 | 3 | export const SUPPORTED_SHORTCUTS: ShortcutsValues[] = [ 4 | { 5 | name: '@yearly', 6 | value: '0 0 1 1 *', 7 | }, 8 | { 9 | name: '@annually', 10 | value: '0 0 1 1 *', 11 | }, 12 | { 13 | name: '@monthly', 14 | value: '0 0 1 * *', 15 | }, 16 | { 17 | name: '@weekly', 18 | value: '0 0 * * 0', 19 | }, 20 | { 21 | name: '@daily', 22 | value: '0 0 * * *', 23 | }, 24 | { 25 | name: '@midnight', 26 | value: '0 0 * * *', 27 | }, 28 | { 29 | name: '@hourly', 30 | value: '0 * * * *', 31 | }, 32 | ] 33 | export const UNITS: Unit[] = [ 34 | { 35 | type: 'minutes', 36 | min: 0, 37 | max: 59, 38 | total: 60, 39 | }, 40 | { 41 | type: 'hours', 42 | min: 0, 43 | max: 23, 44 | total: 24, 45 | }, 46 | { 47 | type: 'month-days', 48 | min: 1, 49 | max: 31, 50 | total: 31, 51 | }, 52 | { 53 | type: 'months', 54 | min: 1, 55 | max: 12, 56 | total: 12, 57 | // DO NO EDIT 58 | // Only used internally for Cron syntax 59 | // alt values used for labels are in ./locale.ts file 60 | alt: [ 61 | 'JAN', 62 | 'FEB', 63 | 'MAR', 64 | 'APR', 65 | 'MAY', 66 | 'JUN', 67 | 'JUL', 68 | 'AUG', 69 | 'SEP', 70 | 'OCT', 71 | 'NOV', 72 | 'DEC', 73 | ], 74 | }, 75 | { 76 | type: 'week-days', 77 | min: 0, 78 | max: 6, 79 | total: 7, 80 | // DO NO EDIT 81 | // Only used internally for Cron syntax 82 | // alt values used for labels are in ./locale.ts file 83 | alt: ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'], 84 | }, 85 | ] 86 | -------------------------------------------------------------------------------- /src/converter.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject } from 'react' 2 | 3 | import { SUPPORTED_SHORTCUTS, UNITS } from './constants' 4 | import { 5 | AllowEmpty, 6 | ClockFormat, 7 | DropdownConfig, 8 | DropdownsConfig, 9 | LeadingZero, 10 | Locale, 11 | OnError, 12 | PeriodType, 13 | SetInternalError, 14 | SetValueNumbersOrUndefined, 15 | SetValuePeriod, 16 | Shortcuts, 17 | Unit, 18 | } from './types' 19 | import { convertStringToNumber, dedup, range, setError, sort } from './utils' 20 | 21 | /** 22 | * Set values from cron string 23 | */ 24 | export function setValuesFromCronString( 25 | cronString: string, 26 | setInternalError: SetInternalError, 27 | onError: OnError, 28 | allowEmpty: AllowEmpty, 29 | internalValueRef: MutableRefObject, 30 | firstRender: boolean, 31 | locale: Locale, 32 | shortcuts: Shortcuts, 33 | setMinutes: SetValueNumbersOrUndefined, 34 | setHours: SetValueNumbersOrUndefined, 35 | setMonthDays: SetValueNumbersOrUndefined, 36 | setMonths: SetValueNumbersOrUndefined, 37 | setWeekDays: SetValueNumbersOrUndefined, 38 | setPeriod: SetValuePeriod 39 | ) { 40 | onError && onError(undefined) 41 | setInternalError(false) 42 | 43 | let error = false 44 | 45 | // Handle empty cron string 46 | if (!cronString) { 47 | if ( 48 | allowEmpty === 'always' || 49 | (firstRender && allowEmpty === 'for-default-value') 50 | ) { 51 | return 52 | } 53 | 54 | error = true 55 | } 56 | 57 | if (!error) { 58 | // Shortcuts management 59 | if ( 60 | shortcuts && 61 | (shortcuts === true || shortcuts.includes(cronString as any)) 62 | ) { 63 | if (cronString === '@reboot') { 64 | setPeriod('reboot') 65 | 66 | return 67 | } 68 | 69 | // Convert a shortcut to a valid cron string 70 | const shortcutObject = SUPPORTED_SHORTCUTS.find( 71 | (supportedShortcut) => supportedShortcut.name === cronString 72 | ) 73 | 74 | if (shortcutObject) { 75 | cronString = shortcutObject.value 76 | } 77 | } 78 | 79 | try { 80 | const cronParts = parseCronString(cronString) 81 | const period = getPeriodFromCronParts(cronParts) 82 | 83 | setPeriod(period) 84 | setMinutes(cronParts[0]) 85 | setHours(cronParts[1]) 86 | setMonthDays(cronParts[2]) 87 | setMonths(cronParts[3]) 88 | setWeekDays(cronParts[4]) 89 | } catch (err) { 90 | // Specific errors are not handle (yet) 91 | error = true 92 | } 93 | } 94 | if (error) { 95 | internalValueRef.current = cronString 96 | setInternalError(true) 97 | setError(onError, locale) 98 | } 99 | } 100 | 101 | /** 102 | * Get cron string from values 103 | */ 104 | export function getCronStringFromValues( 105 | period: PeriodType, 106 | months: number[] | undefined, 107 | monthDays: number[] | undefined, 108 | weekDays: number[] | undefined, 109 | hours: number[] | undefined, 110 | minutes: number[] | undefined, 111 | humanizeValue: boolean | undefined, 112 | dropdownsConfig: DropdownsConfig | undefined 113 | ) { 114 | if (period === 'reboot') { 115 | return '@reboot' 116 | } 117 | 118 | const newMonths = period === 'year' && months ? months : [] 119 | const newMonthDays = 120 | (period === 'year' || period === 'month') && monthDays ? monthDays : [] 121 | const newWeekDays = 122 | (period === 'year' || period === 'month' || period === 'week') && weekDays 123 | ? weekDays 124 | : [] 125 | const newHours = 126 | period !== 'minute' && period !== 'hour' && hours ? hours : [] 127 | const newMinutes = period !== 'minute' && minutes ? minutes : [] 128 | 129 | const parsedArray = parseCronArray( 130 | [newMinutes, newHours, newMonthDays, newMonths, newWeekDays], 131 | humanizeValue, 132 | dropdownsConfig 133 | ) 134 | 135 | return cronToString(parsedArray) 136 | } 137 | 138 | /** 139 | * Returns the cron part array as a string. 140 | */ 141 | export function partToString( 142 | cronPart: number[], 143 | unit: Unit, 144 | humanize?: boolean, 145 | leadingZero?: LeadingZero, 146 | clockFormat?: ClockFormat 147 | ) { 148 | let retval = '' 149 | 150 | if (isFull(cronPart, unit) || cronPart.length === 0) { 151 | retval = '*' 152 | } else { 153 | const step = getStep(cronPart) 154 | 155 | if (step && isInterval(cronPart, step)) { 156 | if (isFullInterval(cronPart, unit, step)) { 157 | retval = `*/${step}` 158 | } else { 159 | retval = `${formatValue( 160 | getMin(cronPart), 161 | unit, 162 | humanize, 163 | leadingZero, 164 | clockFormat 165 | )}-${formatValue( 166 | getMax(cronPart), 167 | unit, 168 | humanize, 169 | leadingZero, 170 | clockFormat 171 | )}/${step}` 172 | } 173 | } else { 174 | retval = toRanges(cronPart) 175 | .map((range: number | number[]) => { 176 | if (Array.isArray(range)) { 177 | return `${formatValue( 178 | range[0], 179 | unit, 180 | humanize, 181 | leadingZero, 182 | clockFormat 183 | )}-${formatValue( 184 | range[1], 185 | unit, 186 | humanize, 187 | leadingZero, 188 | clockFormat 189 | )}` 190 | } 191 | 192 | return formatValue(range, unit, humanize, leadingZero, clockFormat) 193 | }) 194 | .join(',') 195 | } 196 | } 197 | return retval 198 | } 199 | 200 | /** 201 | * Format the value 202 | */ 203 | export function formatValue( 204 | value: number, 205 | unit: Unit, 206 | humanize?: boolean, 207 | leadingZero?: LeadingZero, 208 | clockFormat?: ClockFormat 209 | ) { 210 | let cronPartString = value.toString() 211 | const { type, alt, min } = unit 212 | const needLeadingZero = 213 | leadingZero && (leadingZero === true || leadingZero.includes(type as any)) 214 | const need24HourClock = 215 | clockFormat === '24-hour-clock' && (type === 'hours' || type === 'minutes') 216 | 217 | if ((humanize && type === 'week-days') || (humanize && type === 'months')) { 218 | cronPartString = alt![value - min] 219 | } else if (value < 10 && (needLeadingZero || need24HourClock)) { 220 | cronPartString = cronPartString.padStart(2, '0') 221 | } 222 | 223 | if (type === 'hours' && clockFormat === '12-hour-clock') { 224 | const suffix = value >= 12 ? 'PM' : 'AM' 225 | let hour: number | string = value % 12 || 12 226 | 227 | if (hour < 10 && needLeadingZero) { 228 | hour = hour.toString().padStart(2, '0') 229 | } 230 | 231 | cronPartString = `${hour}${suffix}` 232 | } 233 | 234 | return cronPartString 235 | } 236 | 237 | /** 238 | * Validates a range of positive integers 239 | */ 240 | export function parsePartArray(arr: number[], unit: Unit) { 241 | const values = sort(dedup(fixSunday(arr, unit))) 242 | 243 | if (values.length === 0) { 244 | return values 245 | } 246 | 247 | const value = outOfRange(values, unit) 248 | 249 | if (typeof value !== 'undefined') { 250 | throw new Error(`Value "${value}" out of range for ${unit.type}`) 251 | } 252 | 253 | return values 254 | } 255 | 256 | /** 257 | * Parses a 2-dimensional array of integers as a cron schedule 258 | */ 259 | function parseCronArray( 260 | cronArr: number[][], 261 | humanizeValue: boolean | undefined, 262 | dropdownsConfig: DropdownsConfig | undefined 263 | ) { 264 | return cronArr.map((partArr, idx) => { 265 | const unit = UNITS[idx] 266 | const parsedArray = parsePartArray(partArr, unit) 267 | const dropdownOption: DropdownConfig | undefined = 268 | dropdownsConfig?.[unit.type] 269 | 270 | return partToString( 271 | parsedArray, 272 | unit, 273 | dropdownOption?.humanizeValue ?? humanizeValue 274 | ) 275 | }) 276 | } 277 | 278 | /** 279 | * Returns the cron array as a string 280 | */ 281 | function cronToString(parts: string[]) { 282 | return parts.join(' ') 283 | } 284 | 285 | /** 286 | * Find the period from cron parts 287 | */ 288 | function getPeriodFromCronParts(cronParts: number[][]): PeriodType { 289 | if (cronParts[3].length > 0) { 290 | return 'year' 291 | } else if (cronParts[2].length > 0) { 292 | return 'month' 293 | } else if (cronParts[4].length > 0) { 294 | return 'week' 295 | } else if (cronParts[1].length > 0) { 296 | return 'day' 297 | } else if (cronParts[0].length > 0) { 298 | return 'hour' 299 | } 300 | return 'minute' 301 | } 302 | 303 | /** 304 | * Parses a cron string to an array of parts 305 | */ 306 | export function parseCronString(str: string) { 307 | if (typeof str !== 'string') { 308 | throw new Error('Invalid cron string') 309 | } 310 | 311 | const parts = str.replace(/\s+/g, ' ').trim().split(' ') 312 | 313 | if (parts.length === 5) { 314 | return parts.map((partStr, idx) => { 315 | return parsePartString(partStr, UNITS[idx]) 316 | }) 317 | } 318 | 319 | throw new Error('Invalid cron string format') 320 | } 321 | 322 | /** 323 | * Parses a string as a range of positive integers 324 | */ 325 | function parsePartString(str: string, unit: Unit) { 326 | if (str === '*' || str === '*/1') { 327 | return [] 328 | } 329 | 330 | const values = sort( 331 | dedup( 332 | fixSunday( 333 | replaceAlternatives(str, unit.min, unit.alt) 334 | .split(',') 335 | .map((value) => { 336 | const valueParts = value.split('/') 337 | 338 | if (valueParts.length > 2) { 339 | throw new Error(`Invalid value "${str} for "${unit.type}"`) 340 | } 341 | 342 | let parsedValues: number[] 343 | const left = valueParts[0] 344 | const right = valueParts[1] 345 | 346 | if (left === '*') { 347 | parsedValues = range(unit.min, unit.max) 348 | } else { 349 | parsedValues = parseRange(left, str, unit) 350 | } 351 | 352 | const step = parseStep(right, unit) 353 | const intervalValues = applyInterval(parsedValues, step) 354 | 355 | return intervalValues 356 | }) 357 | .flat(), 358 | unit 359 | ) 360 | ) 361 | ) 362 | 363 | const value = outOfRange(values, unit) 364 | 365 | if (typeof value !== 'undefined') { 366 | throw new Error(`Value "${value}" out of range for ${unit.type}`) 367 | } 368 | 369 | // Prevent to return full array 370 | // If all values are selected we don't want any selection visible 371 | if (values.length === unit.total) { 372 | return [] 373 | } 374 | 375 | return values 376 | } 377 | 378 | /** 379 | * Replaces the alternative representations of numbers in a string 380 | */ 381 | function replaceAlternatives(str: string, min: number, alt?: string[]) { 382 | if (alt) { 383 | str = str.toUpperCase() 384 | 385 | for (let i = 0; i < alt.length; i++) { 386 | str = str.replace(alt[i], `${i + min}`) 387 | } 388 | } 389 | return str 390 | } 391 | 392 | /** 393 | * Replace all 7 with 0 as Sunday can be represented by both 394 | */ 395 | function fixSunday(values: number[], unit: Unit) { 396 | if (unit.type === 'week-days') { 397 | values = values.map(function (value) { 398 | if (value === 7) { 399 | return 0 400 | } 401 | 402 | return value 403 | }) 404 | } 405 | 406 | return values 407 | } 408 | 409 | /** 410 | * Parses a range string 411 | */ 412 | function parseRange(rangeStr: string, context: string, unit: Unit) { 413 | const subparts = rangeStr.split('-') 414 | 415 | if (subparts.length === 1) { 416 | const value = convertStringToNumber(subparts[0]) 417 | 418 | if (isNaN(value)) { 419 | throw new Error(`Invalid value "${context}" for ${unit.type}`) 420 | } 421 | 422 | return [value] 423 | } else if (subparts.length === 2) { 424 | const minValue = convertStringToNumber(subparts[0]) 425 | const maxValue = convertStringToNumber(subparts[1]) 426 | 427 | if (isNaN(minValue) || isNaN(maxValue)) { 428 | throw new Error(`Invalid value "${context}" for ${unit.type}`) 429 | } 430 | 431 | // Fix to allow equal min and max range values 432 | // cf: https://github.com/roccivic/cron-converter/pull/15 433 | if (maxValue < minValue) { 434 | throw new Error( 435 | `Max range is less than min range in "${rangeStr}" for ${unit.type}` 436 | ) 437 | } 438 | 439 | return range(minValue, maxValue) 440 | } else { 441 | throw new Error(`Invalid value "${rangeStr}" for ${unit.type}`) 442 | } 443 | } 444 | 445 | /** 446 | * Finds an element from values that is outside of the range of unit 447 | */ 448 | function outOfRange(values: number[], unit: Unit) { 449 | const first = values[0] 450 | const last = values[values.length - 1] 451 | 452 | if (first < unit.min) { 453 | return first 454 | } else if (last > unit.max) { 455 | return last 456 | } 457 | 458 | return 459 | } 460 | 461 | /** 462 | * Parses the step from a part string 463 | */ 464 | function parseStep(step: string, unit: Unit) { 465 | if (typeof step !== 'undefined') { 466 | const parsedStep = convertStringToNumber(step) 467 | 468 | if (isNaN(parsedStep) || parsedStep < 1) { 469 | throw new Error(`Invalid interval step value "${step}" for ${unit.type}`) 470 | } 471 | 472 | return parsedStep 473 | } 474 | } 475 | 476 | /** 477 | * Applies an interval step to a collection of values 478 | */ 479 | function applyInterval(values: number[], step?: number) { 480 | if (step) { 481 | const minVal = values[0] 482 | 483 | values = values.filter((value) => { 484 | return value % step === minVal % step || value === minVal 485 | }) 486 | } 487 | 488 | return values 489 | } 490 | 491 | /** 492 | * Returns true if range has all the values of the unit 493 | */ 494 | function isFull(values: number[], unit: Unit) { 495 | return values.length === unit.max - unit.min + 1 496 | } 497 | 498 | /** 499 | * Returns the difference between first and second elements in the range 500 | */ 501 | function getStep(values: number[]) { 502 | if (values.length > 2) { 503 | const step = values[1] - values[0] 504 | 505 | if (step > 1) { 506 | return step 507 | } 508 | } 509 | } 510 | 511 | /** 512 | * Returns true if the range can be represented as an interval 513 | */ 514 | function isInterval(values: number[], step: number) { 515 | for (let i = 1; i < values.length; i++) { 516 | const prev = values[i - 1] 517 | const value = values[i] 518 | 519 | if (value - prev !== step) { 520 | return false 521 | } 522 | } 523 | 524 | return true 525 | } 526 | 527 | /** 528 | * Returns true if the range contains all the interval values 529 | */ 530 | function isFullInterval(values: number[], unit: Unit, step: number) { 531 | const min = getMin(values) 532 | const max = getMax(values) 533 | const haveAllValues = values.length === (max - min) / step + 1 534 | 535 | if (min === unit.min && max + step > unit.max && haveAllValues) { 536 | return true 537 | } 538 | 539 | return false 540 | } 541 | 542 | /** 543 | * Returns the smallest value in the range 544 | */ 545 | function getMin(values: number[]) { 546 | return values[0] 547 | } 548 | 549 | /** 550 | * Returns the largest value in the range 551 | */ 552 | function getMax(values: number[]) { 553 | return values[values.length - 1] 554 | } 555 | 556 | /** 557 | * Returns the range as an array of ranges 558 | * defined as arrays of positive integers 559 | */ 560 | function toRanges(values: number[]) { 561 | const retval: (number[] | number)[] = [] 562 | let startPart: number | null = null 563 | 564 | values.forEach((value, index, self) => { 565 | if (value !== self[index + 1] - 1) { 566 | if (startPart !== null) { 567 | retval.push([startPart, value]) 568 | startPart = null 569 | } else { 570 | retval.push(value) 571 | } 572 | } else if (startPart === null) { 573 | startPart = value 574 | } 575 | }) 576 | 577 | return retval 578 | } 579 | -------------------------------------------------------------------------------- /src/fields/Hours.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react' 2 | 3 | import CustomSelect from '../components/CustomSelect' 4 | import { UNITS } from '../constants' 5 | import { DEFAULT_LOCALE_EN } from '../locale' 6 | import { HoursProps } from '../types' 7 | import { classNames } from '../utils' 8 | 9 | export default function Hours(props: HoursProps) { 10 | const { 11 | value, 12 | setValue, 13 | locale, 14 | className, 15 | disabled, 16 | readOnly, 17 | leadingZero, 18 | clockFormat, 19 | period, 20 | periodicityOnDoubleClick, 21 | mode, 22 | allowClear, 23 | filterOption, 24 | getPopupContainer, 25 | } = props 26 | const internalClassName = useMemo( 27 | () => 28 | classNames({ 29 | 'react-js-cron-field': true, 30 | 'react-js-cron-hours': true, 31 | [`${className}-field`]: !!className, 32 | [`${className}-hours`]: !!className, 33 | }), 34 | [className] 35 | ) 36 | 37 | return ( 38 |
39 | {locale.prefixHours !== '' && ( 40 | {locale.prefixHours || DEFAULT_LOCALE_EN.prefixHours} 41 | )} 42 | 43 | 61 |
62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /src/fields/Minutes.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react' 2 | 3 | import CustomSelect from '../components/CustomSelect' 4 | import { UNITS } from '../constants' 5 | import { DEFAULT_LOCALE_EN } from '../locale' 6 | import { MinutesProps } from '../types' 7 | import { classNames } from '../utils' 8 | 9 | export default function Minutes(props: MinutesProps) { 10 | const { 11 | value, 12 | setValue, 13 | locale, 14 | className, 15 | disabled, 16 | readOnly, 17 | leadingZero, 18 | clockFormat, 19 | period, 20 | periodicityOnDoubleClick, 21 | mode, 22 | allowClear, 23 | filterOption, 24 | getPopupContainer, 25 | } = props 26 | const internalClassName = useMemo( 27 | () => 28 | classNames({ 29 | 'react-js-cron-field': true, 30 | 'react-js-cron-minutes': true, 31 | [`${className}-field`]: !!className, 32 | [`${className}-minutes`]: !!className, 33 | }), 34 | [className] 35 | ) 36 | 37 | return ( 38 |
39 | {period === 'hour' 40 | ? locale.prefixMinutesForHourPeriod !== '' && ( 41 | 42 | {locale.prefixMinutesForHourPeriod || 43 | DEFAULT_LOCALE_EN.prefixMinutesForHourPeriod} 44 | 45 | ) 46 | : locale.prefixMinutes !== '' && ( 47 | 48 | {locale.prefixMinutes || DEFAULT_LOCALE_EN.prefixMinutes} 49 | 50 | )} 51 | 52 | 75 | 76 | {period === 'hour' && locale.suffixMinutesForHourPeriod !== '' && ( 77 | 78 | {locale.suffixMinutesForHourPeriod || 79 | DEFAULT_LOCALE_EN.suffixMinutesForHourPeriod} 80 | 81 | )} 82 |
83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /src/fields/MonthDays.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react' 2 | 3 | import CustomSelect from '../components/CustomSelect' 4 | import { UNITS } from '../constants' 5 | import { DEFAULT_LOCALE_EN } from '../locale' 6 | import { MonthDaysProps } from '../types' 7 | import { classNames } from '../utils' 8 | 9 | export default function MonthDays(props: MonthDaysProps) { 10 | const { 11 | value, 12 | setValue, 13 | locale, 14 | className, 15 | weekDays, 16 | disabled, 17 | readOnly, 18 | leadingZero, 19 | period, 20 | periodicityOnDoubleClick, 21 | mode, 22 | allowClear, 23 | filterOption, 24 | getPopupContainer, 25 | } = props 26 | const noWeekDays = !weekDays || weekDays.length === 0 27 | 28 | const internalClassName = useMemo( 29 | () => 30 | classNames({ 31 | 'react-js-cron-field': true, 32 | 'react-js-cron-month-days': true, 33 | 'react-js-cron-month-days-placeholder': !noWeekDays, 34 | [`${className}-field`]: !!className, 35 | [`${className}-month-days`]: !!className, 36 | }), 37 | [className, noWeekDays] 38 | ) 39 | 40 | const localeJSON = JSON.stringify(locale) 41 | const placeholder = useMemo( 42 | () => { 43 | if (noWeekDays) { 44 | return locale.emptyMonthDays || DEFAULT_LOCALE_EN.emptyMonthDays 45 | } 46 | 47 | return locale.emptyMonthDaysShort || DEFAULT_LOCALE_EN.emptyMonthDaysShort 48 | }, 49 | // eslint-disable-next-line react-hooks/exhaustive-deps 50 | [noWeekDays, localeJSON] 51 | ) 52 | 53 | const displayMonthDays = 54 | !readOnly || 55 | (value && value.length > 0) || 56 | ((!value || value.length === 0) && (!weekDays || weekDays.length === 0)) 57 | 58 | return displayMonthDays ? ( 59 |
60 | {locale.prefixMonthDays !== '' && ( 61 | 62 | {locale.prefixMonthDays || DEFAULT_LOCALE_EN.prefixMonthDays} 63 | 64 | )} 65 | 66 | 83 |
84 | ) : null 85 | } 86 | -------------------------------------------------------------------------------- /src/fields/Months.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react' 2 | 3 | import CustomSelect from '../components/CustomSelect' 4 | import { UNITS } from '../constants' 5 | import { DEFAULT_LOCALE_EN } from '../locale' 6 | import { MonthsProps } from '../types' 7 | import { classNames } from '../utils' 8 | 9 | export default function Months(props: MonthsProps) { 10 | const { 11 | value, 12 | setValue, 13 | locale, 14 | className, 15 | humanizeLabels, 16 | disabled, 17 | readOnly, 18 | period, 19 | periodicityOnDoubleClick, 20 | mode, 21 | allowClear, 22 | filterOption, 23 | getPopupContainer, 24 | } = props 25 | const optionsList = locale.months || DEFAULT_LOCALE_EN.months 26 | 27 | const internalClassName = useMemo( 28 | () => 29 | classNames({ 30 | 'react-js-cron-field': true, 31 | 'react-js-cron-months': true, 32 | [`${className}-field`]: !!className, 33 | [`${className}-months`]: !!className, 34 | }), 35 | [className] 36 | ) 37 | 38 | return ( 39 |
40 | {locale.prefixMonths !== '' && ( 41 | {locale.prefixMonths || DEFAULT_LOCALE_EN.prefixMonths} 42 | )} 43 | 44 | 68 |
69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /src/fields/Period.tsx: -------------------------------------------------------------------------------- 1 | import { Select } from 'antd' 2 | import { BaseOptionType } from 'antd/es/select' 3 | import React, { useCallback, useMemo } from 'react' 4 | 5 | import { DEFAULT_LOCALE_EN } from '../locale' 6 | import { PeriodProps, PeriodType } from '../types' 7 | import { classNames } from '../utils' 8 | 9 | export default function Period(props: PeriodProps) { 10 | const { 11 | value, 12 | setValue, 13 | locale, 14 | className, 15 | disabled, 16 | readOnly, 17 | shortcuts, 18 | allowedPeriods, 19 | allowClear, 20 | getPopupContainer, 21 | } = props 22 | const options: BaseOptionType[] = [] 23 | 24 | if (allowedPeriods.includes('year')) { 25 | options.push({ 26 | value: 'year', 27 | label: locale.yearOption || DEFAULT_LOCALE_EN.yearOption, 28 | }) 29 | } 30 | 31 | if (allowedPeriods.includes('month')) { 32 | options.push({ 33 | value: 'month', 34 | label: locale.monthOption || DEFAULT_LOCALE_EN.monthOption, 35 | }) 36 | } 37 | 38 | if (allowedPeriods.includes('week')) { 39 | options.push({ 40 | value: 'week', 41 | label: locale.weekOption || DEFAULT_LOCALE_EN.weekOption, 42 | }) 43 | } 44 | 45 | if (allowedPeriods.includes('day')) { 46 | options.push({ 47 | value: 'day', 48 | label: locale.dayOption || DEFAULT_LOCALE_EN.dayOption, 49 | }) 50 | } 51 | 52 | if (allowedPeriods.includes('hour')) { 53 | options.push({ 54 | value: 'hour', 55 | label: locale.hourOption || DEFAULT_LOCALE_EN.hourOption, 56 | }) 57 | } 58 | 59 | if (allowedPeriods.includes('minute')) { 60 | options.push({ 61 | value: 'minute', 62 | label: locale.minuteOption || DEFAULT_LOCALE_EN.minuteOption, 63 | }) 64 | } 65 | 66 | if ( 67 | allowedPeriods.includes('reboot') && 68 | shortcuts && 69 | (shortcuts === true || shortcuts.includes('@reboot')) 70 | ) { 71 | options.push({ 72 | value: 'reboot', 73 | label: locale.rebootOption || DEFAULT_LOCALE_EN.rebootOption, 74 | }) 75 | } 76 | 77 | const handleChange = useCallback( 78 | (newValue: PeriodType) => { 79 | if (!readOnly) { 80 | setValue(newValue) 81 | } 82 | }, 83 | [setValue, readOnly] 84 | ) 85 | 86 | const internalClassName = useMemo( 87 | () => 88 | classNames({ 89 | 'react-js-cron-field': true, 90 | 'react-js-cron-period': true, 91 | [`${className}-field`]: !!className, 92 | [`${className}-period`]: !!className, 93 | }), 94 | [className] 95 | ) 96 | 97 | const selectClassName = useMemo( 98 | () => 99 | classNames({ 100 | 'react-js-cron-select': true, 101 | 'react-js-cron-select-no-prefix': locale.prefixPeriod === '', 102 | [`${className}-select`]: !!className, 103 | }), 104 | [className, locale.prefixPeriod] 105 | ) 106 | 107 | const popupClassName = useMemo( 108 | () => 109 | classNames({ 110 | 'react-js-cron-select-dropdown': true, 111 | 'react-js-cron-select-dropdown-period': true, 112 | [`${className}-select-dropdown`]: !!className, 113 | [`${className}-select-dropdown-period`]: !!className, 114 | }), 115 | [className] 116 | ) 117 | 118 | return ( 119 |
120 | {locale.prefixPeriod !== '' && ( 121 | {locale.prefixPeriod || DEFAULT_LOCALE_EN.prefixPeriod} 122 | )} 123 | 124 | 125 | key={JSON.stringify(locale)} 126 | defaultValue={value} 127 | value={value} 128 | onChange={handleChange} 129 | options={options} 130 | className={selectClassName} 131 | popupClassName={popupClassName} 132 | disabled={disabled} 133 | suffixIcon={readOnly ? null : undefined} 134 | open={readOnly ? false : undefined} 135 | data-testid='select-period' 136 | allowClear={allowClear} 137 | getPopupContainer={getPopupContainer} 138 | /> 139 |
140 | ) 141 | } 142 | -------------------------------------------------------------------------------- /src/fields/WeekDays.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react' 2 | 3 | import CustomSelect from '../components/CustomSelect' 4 | import { UNITS } from '../constants' 5 | import { DEFAULT_LOCALE_EN } from '../locale' 6 | import { WeekDaysProps } from '../types' 7 | import { classNames } from '../utils' 8 | 9 | export default function WeekDays(props: WeekDaysProps) { 10 | const { 11 | value, 12 | setValue, 13 | locale, 14 | className, 15 | humanizeLabels, 16 | monthDays, 17 | disabled, 18 | readOnly, 19 | period, 20 | periodicityOnDoubleClick, 21 | mode, 22 | allowClear, 23 | filterOption, 24 | getPopupContainer, 25 | } = props 26 | const optionsList = locale.weekDays || DEFAULT_LOCALE_EN.weekDays 27 | const noMonthDays = period === 'week' || !monthDays || monthDays.length === 0 28 | 29 | const internalClassName = useMemo( 30 | () => 31 | classNames({ 32 | 'react-js-cron-field': true, 33 | 'react-js-cron-week-days': true, 34 | 'react-js-cron-week-days-placeholder': !noMonthDays, 35 | [`${className}-field`]: !!className, 36 | [`${className}-week-days`]: !!className, 37 | }), 38 | [className, noMonthDays] 39 | ) 40 | 41 | const localeJSON = JSON.stringify(locale) 42 | const placeholder = useMemo( 43 | () => { 44 | if (noMonthDays) { 45 | return locale.emptyWeekDays || DEFAULT_LOCALE_EN.emptyWeekDays 46 | } 47 | 48 | return locale.emptyWeekDaysShort || DEFAULT_LOCALE_EN.emptyWeekDaysShort 49 | }, 50 | // eslint-disable-next-line react-hooks/exhaustive-deps 51 | [noMonthDays, localeJSON] 52 | ) 53 | 54 | const displayWeekDays = 55 | period === 'week' || 56 | !readOnly || 57 | (value && value.length > 0) || 58 | ((!value || value.length === 0) && (!monthDays || monthDays.length === 0)) 59 | 60 | const monthDaysIsDisplayed = 61 | !readOnly || 62 | (monthDays && monthDays.length > 0) || 63 | ((!monthDays || monthDays.length === 0) && (!value || value.length === 0)) 64 | 65 | return displayWeekDays ? ( 66 |
67 | {locale.prefixWeekDays !== '' && 68 | (period === 'week' || !monthDaysIsDisplayed) && ( 69 | 70 | {locale.prefixWeekDays || DEFAULT_LOCALE_EN.prefixWeekDays} 71 | 72 | )} 73 | 74 | {locale.prefixWeekDaysForMonthAndYearPeriod !== '' && 75 | period !== 'week' && 76 | monthDaysIsDisplayed && ( 77 | 78 | {locale.prefixWeekDaysForMonthAndYearPeriod || 79 | DEFAULT_LOCALE_EN.prefixWeekDaysForMonthAndYearPeriod} 80 | 81 | )} 82 | 83 | 107 |
108 | ) : null 109 | } 110 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Cron from './Cron' 2 | import * as converter from './converter' 3 | 4 | export * from './types' 5 | 6 | // Support "import { Cron } from 'react-js-cron'" 7 | // Support "import { Cron as ReactJSCron } from 'react-js-cron'" 8 | export { Cron, converter } 9 | 10 | // Support "import Cron from 'react-js-cron'" 11 | export default Cron 12 | -------------------------------------------------------------------------------- /src/locale.ts: -------------------------------------------------------------------------------- 1 | import { DefaultLocale } from './types' 2 | 3 | export const DEFAULT_LOCALE_EN: DefaultLocale = { 4 | everyText: 'every', 5 | emptyMonths: 'every month', 6 | emptyMonthDays: 'every day of the month', 7 | emptyMonthDaysShort: 'day of the month', 8 | emptyWeekDays: 'every day of the week', 9 | emptyWeekDaysShort: 'day of the week', 10 | emptyHours: 'every hour', 11 | emptyMinutes: 'every minute', 12 | emptyMinutesForHourPeriod: 'every', 13 | yearOption: 'year', 14 | monthOption: 'month', 15 | weekOption: 'week', 16 | dayOption: 'day', 17 | hourOption: 'hour', 18 | minuteOption: 'minute', 19 | rebootOption: 'reboot', 20 | prefixPeriod: 'Every', 21 | prefixMonths: 'in', 22 | prefixMonthDays: 'on', 23 | prefixWeekDays: 'on', 24 | prefixWeekDaysForMonthAndYearPeriod: 'and', 25 | prefixHours: 'at', 26 | prefixMinutes: ':', 27 | prefixMinutesForHourPeriod: 'at', 28 | suffixMinutesForHourPeriod: 'minute(s)', 29 | errorInvalidCron: 'Invalid cron expression', 30 | clearButtonText: 'Clear', 31 | weekDays: [ 32 | // Order is important, the index will be used as value 33 | 'Sunday', // Sunday must always be first, it's "0" 34 | 'Monday', 35 | 'Tuesday', 36 | 'Wednesday', 37 | 'Thursday', 38 | 'Friday', 39 | 'Saturday', 40 | ], 41 | months: [ 42 | // Order is important, the index will be used as value 43 | 'January', 44 | 'February', 45 | 'March', 46 | 'April', 47 | 'May', 48 | 'June', 49 | 'July', 50 | 'August', 51 | 'September', 52 | 'October', 53 | 'November', 54 | 'December', 55 | ], 56 | // Order is important, the index will be used as value 57 | altWeekDays: [ 58 | 'SUN', // Sunday must always be first, it's "0" 59 | 'MON', 60 | 'TUE', 61 | 'WED', 62 | 'THU', 63 | 'FRI', 64 | 'SAT', 65 | ], 66 | // Order is important, the index will be used as value 67 | altMonths: [ 68 | 'JAN', 69 | 'FEB', 70 | 'MAR', 71 | 'APR', 72 | 'MAY', 73 | 'JUN', 74 | 'JUL', 75 | 'AUG', 76 | 'SEP', 77 | 'OCT', 78 | 'NOV', 79 | 'DEC', 80 | ], 81 | } 82 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // react-testing-library renders your components to document.body, 2 | // this adds jest-dom's custom assertions 3 | import '@testing-library/jest-dom' 4 | -------------------------------------------------------------------------------- /src/stories/constants.stories.ts: -------------------------------------------------------------------------------- 1 | export const FRENCH_LOCALE = { 2 | everyText: 'chaque', 3 | emptyMonths: 'chaque mois', 4 | emptyMonthDays: 'chaque jour du mois', 5 | emptyMonthDaysShort: 'jour du mois', 6 | emptyWeekDays: 'chaque jour de la semaine', 7 | emptyWeekDaysShort: 'jour de la semaine', 8 | emptyHours: 'chaque heure', 9 | emptyMinutes: 'chaque minute', 10 | emptyMinutesForHourPeriod: 'chaque', 11 | yearOption: 'année', 12 | monthOption: 'mois', 13 | weekOption: 'semaine', 14 | dayOption: 'jour', 15 | hourOption: 'heure', 16 | minuteOption: 'minute', 17 | rebootOption: 'redémarrage', 18 | prefixPeriod: 'Chaque', 19 | prefixMonths: 'en', 20 | prefixMonthDays: 'le', 21 | prefixWeekDays: 'le', 22 | prefixWeekDaysForMonthAndYearPeriod: 'et', 23 | prefixHours: 'à', 24 | prefixMinutes: ':', 25 | prefixMinutesForHourPeriod: 'à', 26 | suffixMinutesForHourPeriod: 'minute(s)', 27 | errorInvalidCron: 'Expression cron invalide', 28 | clearButtonText: 'Effacer', 29 | // Order is important, the index will be used as value 30 | months: [ 31 | 'janvier', 32 | 'février', 33 | 'mars', 34 | 'avril', 35 | 'mai', 36 | 'juin', 37 | 'juillet', 38 | 'août', 39 | 'septembre', 40 | 'octobre', 41 | 'novembre', 42 | 'décembre', 43 | ], 44 | // Order is important, the index will be used as value 45 | weekDays: [ 46 | 'dimanche', 47 | 'lundi', 48 | 'mardi', 49 | 'mercredi', 50 | 'jeudi', 51 | 'vendredi', 52 | 'samedi', 53 | ], 54 | // cf: https://fr.wikipedia.org/wiki/Mois#Abr%C3%A9viations 55 | // Order is important, the index will be used as value 56 | altMonths: [ 57 | 'JAN', 58 | 'FÉV', 59 | 'MAR', 60 | 'AVR', 61 | 'MAI', 62 | 'JUN', 63 | 'JUL', 64 | 'AOÛ', 65 | 'SEP', 66 | 'OCT', 67 | 'NOV', 68 | 'DÉC', 69 | ], 70 | // cf: http://bdl.oqlf.gouv.qc.ca/bdl/gabarit_bdl.asp?id=3617 71 | // Order is important, the index will be used as value 72 | altWeekDays: ['DIM', 'LUN', 'MAR', 'MER', 'JEU', 'VEN', 'SAM'], 73 | } 74 | export const ENGLISH_VARIANT_LOCALE = { 75 | everyText: 'all', 76 | emptyHours: 'all hours', 77 | emptyWeekDays: 'all days of the week', 78 | emptyMonthDays: 'all days of the month', 79 | emptyMonths: 'all months', 80 | emptyMinutes: 'all minutes', 81 | emptyMinutesForHourPeriod: 'all', 82 | yearOption: 'years', 83 | monthOption: 'months', 84 | weekOption: 'weeks', 85 | dayOption: 'days', 86 | hourOption: 'hours', 87 | minuteOption: 'minutes', 88 | rebootOption: 'reboots', 89 | prefixPeriod: 'All', 90 | } 91 | export const NO_PREFIX_SUFFIX_LOCALE = { 92 | prefixPeriod: '', 93 | prefixMonths: '', 94 | prefixMonthDays: '', 95 | prefixWeekDays: '', 96 | prefixWeekDaysForMonthAndYearPeriod: '', 97 | prefixHours: '', 98 | prefixMinutes: '', 99 | prefixMinutesForHourPeriod: '', 100 | suffixMinutesForHourPeriod: '', 101 | } 102 | -------------------------------------------------------------------------------- /src/stories/styles.stories.css: -------------------------------------------------------------------------------- 1 | .sbdocs-content { 2 | max-width: 1400px !important; 3 | } 4 | .demo-dynamic-settings { 5 | margin-bottom: 10px; 6 | border-bottom: 1px solid #e6e6e6; 7 | } 8 | .demo-dynamic-settings .ant-form-item { 9 | margin-right: 16px; 10 | margin-bottom: 8px; 11 | } 12 | .demo-dynamic-settings p { 13 | width: 100%; 14 | font-size: 12px; 15 | } 16 | .demo-dynamic-settings + div { 17 | padding-bottom: 10px; 18 | margin-bottom: 20px; 19 | border-bottom: 1px solid #e6e6e6; 20 | display: flex; 21 | justify-content: space-between; 22 | align-items: center; 23 | } 24 | .demo-dynamic-settings + div p { 25 | display: inline; 26 | margin: 0; 27 | } 28 | 29 | /* Custom style */ 30 | .my-project-cron-field > span { 31 | font-weight: bold; 32 | } 33 | .my-project-cron-error .my-project-cron-field > span { 34 | color: #ff4d4f; 35 | } 36 | .my-project-cron .my-project-cron-select > div:first-child { 37 | border-radius: 10px; 38 | } 39 | .my-project-cron-select-dropdown 40 | .ant-select-item-option-selected:not(.ant-select-item-option-disabled) { 41 | background-color: #dadada; 42 | } 43 | .my-project-cron-clear-button { 44 | border-radius: 10px; 45 | } 46 | -------------------------------------------------------------------------------- /src/stories/utils.stories.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, useReducer } from 'react' 2 | 3 | /** 4 | * Custom hook to update cron value and input value. 5 | * 6 | * Cannot use InputRef to update the value because of a change in antd 4.19.0. 7 | * 8 | * @param defaultValue - The default value of the input and cron component. 9 | * @returns - The cron and input values with the dispatch function. 10 | */ 11 | export function useCronReducer(defaultValue: string): [ 12 | { 13 | inputValue: string 14 | cronValue: string 15 | }, 16 | Dispatch<{ 17 | type: 'set_cron_value' | 'set_input_value' | 'set_values' 18 | value: string 19 | }> 20 | ] { 21 | const [values, dispatchValues] = useReducer( 22 | ( 23 | prevValues: { 24 | inputValue: string 25 | cronValue: string 26 | }, 27 | action: { 28 | type: 'set_cron_value' | 'set_input_value' | 'set_values' 29 | value: string 30 | } 31 | ) => { 32 | switch (action.type) { 33 | case 'set_cron_value': 34 | return { 35 | inputValue: prevValues.inputValue, 36 | cronValue: action.value, 37 | } 38 | case 'set_input_value': 39 | return { 40 | inputValue: action.value, 41 | cronValue: prevValues.cronValue, 42 | } 43 | case 'set_values': 44 | return { 45 | inputValue: action.value, 46 | cronValue: action.value, 47 | } 48 | } 49 | }, 50 | { 51 | inputValue: defaultValue, 52 | cronValue: defaultValue, 53 | } 54 | ) 55 | 56 | return [values, dispatchValues] 57 | } 58 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | .react-js-cron { 2 | display: flex; 3 | align-items: flex-start; 4 | flex-wrap: wrap; 5 | } 6 | .react-js-cron > div, 7 | .react-js-cron-field { 8 | display: flex; 9 | align-items: center; 10 | } 11 | .react-js-cron-field { 12 | margin-bottom: 10px; 13 | } 14 | .react-js-cron-field > span { 15 | margin-left: 5px; 16 | } 17 | div.react-js-cron-select { 18 | margin-left: 5px; 19 | } 20 | .react-js-cron-select.react-js-cron-select-no-prefix { 21 | margin-left: 0; 22 | } 23 | .react-js-cron-select .ant-select-selection-wrap { 24 | position: relative; 25 | align-items: center; 26 | } 27 | /* Absolute position only when there are one child, meaning when no items are selected. */ 28 | .react-js-cron-select 29 | .ant-select-selection-overflow:has(> :nth-child(-n + 1):last-child) { 30 | position: absolute; 31 | top: 0; 32 | left: 0; 33 | } 34 | /* Center placeholder vertically. */ 35 | .react-js-cron-select .ant-select-selection-placeholder { 36 | margin-top: -2px; 37 | } 38 | div.react-js-cron-error .react-js-cron-select .ant-select-selector { 39 | border-color: #ff4d4f; 40 | background: #fff6f6; 41 | } 42 | div.react-js-cron-custom-select { 43 | min-width: 70px; 44 | z-index: 1; 45 | } 46 | div.react-js-cron-error div.react-js-cron-custom-select { 47 | background: #fff6f6; 48 | } 49 | div.react-js-cron-select.react-js-cron-custom-select.ant-select 50 | div.ant-select-selector { 51 | padding-left: 11px; 52 | padding-right: 30px; 53 | } 54 | .react-js-cron-read-only 55 | div.react-js-cron-select.react-js-cron-custom-select.ant-select 56 | div.ant-select-selector { 57 | padding-right: 11px; 58 | } 59 | div.react-js-cron-custom-select .ant-select-selection-search { 60 | width: 0 !important; 61 | margin: 0 !important; 62 | } 63 | div.react-js-cron-custom-select .ant-select-selection-placeholder { 64 | position: static; 65 | top: 50%; 66 | right: auto; 67 | left: auto; 68 | transform: none; 69 | transition: none; 70 | opacity: 1; 71 | color: inherit; 72 | } 73 | .react-js-cron-week-days-placeholder 74 | .react-js-cron-custom-select 75 | .ant-select-selection-placeholder, 76 | .react-js-cron-month-days-placeholder 77 | .react-js-cron-custom-select 78 | .ant-select-selection-placeholder { 79 | opacity: 0.4; 80 | } 81 | .react-js-cron-custom-select-dropdown { 82 | min-width: 0 !important; 83 | width: 174px !important; 84 | } 85 | .react-js-cron-custom-select-dropdown .rc-virtual-list { 86 | max-height: none !important; 87 | } 88 | .react-js-cron-custom-select-dropdown-grid .rc-virtual-list-holder { 89 | max-height: initial !important; 90 | } 91 | .react-js-cron-custom-select-dropdown-grid .rc-virtual-list-holder-inner { 92 | display: grid !important; 93 | grid-template-columns: repeat(4, 1fr); 94 | } 95 | .react-js-cron-custom-select-dropdown-grid 96 | .rc-virtual-list-holder-inner 97 | .ant-select-item-option-content { 98 | text-align: center; 99 | } 100 | .react-js-cron-custom-select-dropdown-hours-twelve-hour-clock { 101 | width: 260px !important; 102 | } 103 | .react-js-cron-custom-select-dropdown-minutes-large { 104 | width: 300px !important; 105 | } 106 | .react-js-cron-custom-select-dropdown-minutes-large 107 | .rc-virtual-list-holder-inner { 108 | grid-template-columns: repeat(6, 1fr); 109 | } 110 | .react-js-cron-custom-select-dropdown-minutes-medium { 111 | width: 220px !important; 112 | } 113 | .react-js-cron-custom-select-dropdown-minutes-medium 114 | .rc-virtual-list-holder-inner { 115 | grid-template-columns: repeat(5, 1fr); 116 | } 117 | .react-js-cron-period > span:first-child { 118 | margin-left: 0 !important; 119 | } 120 | .react-js-cron-period 121 | .react-js-cron-select.ant-select-single.ant-select-open 122 | .ant-select-selection-item { 123 | opacity: 1; 124 | } 125 | .react-js-cron-select-dropdown-period { 126 | min-width: 0 !important; 127 | width: auto !important; 128 | } 129 | .react-js-cron-clear-button { 130 | margin-left: 10px; 131 | margin-bottom: 10px; 132 | } 133 | .react-js-cron-disabled .react-js-cron-select.ant-select-disabled { 134 | background: #f5f5f5; 135 | } 136 | div.react-js-cron-select.react-js-cron-custom-select.ant-select 137 | div.ant-select-selector 138 | > .ant-select-selection-overflow { 139 | align-items: center; 140 | flex: initial; 141 | } 142 | -------------------------------------------------------------------------------- /src/tests/Cron.defaultValue.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | 3 | import Cron from '../Cron' 4 | import { 5 | AllowEmpty, 6 | ClockFormat, 7 | CronError, 8 | CronType, 9 | DropdownsConfig, 10 | LeadingZero, 11 | PeriodType, 12 | Shortcuts, 13 | } from '../types' 14 | 15 | describe('Cron defaultValue test suite', () => { 16 | const defaultError: CronError = { 17 | description: 'Invalid cron expression', 18 | type: 'invalid_cron', 19 | } 20 | 21 | const cases: { 22 | title: string 23 | defaultValue: string 24 | expectedValue?: string 25 | expectedPeriod?: PeriodType 26 | shortcuts?: Shortcuts 27 | allowEmpty?: AllowEmpty 28 | humanizeLabels?: boolean 29 | humanizeValue?: boolean 30 | leadingZero?: LeadingZero 31 | clockFormat?: ClockFormat 32 | allowedDropdowns?: CronType[] 33 | defaultPeriod?: PeriodType 34 | periodSelect: PeriodType | undefined 35 | monthsSelect: string | undefined 36 | monthDaysSelect: string | undefined 37 | weekDaysSelect: string | undefined 38 | hoursSelect: string | undefined 39 | minutesSelect: string | undefined 40 | dropdownsConfig?: DropdownsConfig 41 | error?: CronError 42 | }[] = [ 43 | { 44 | title: 'every minutes', 45 | defaultValue: '* * * * *', 46 | periodSelect: 'minute', 47 | monthsSelect: undefined, 48 | monthDaysSelect: undefined, 49 | weekDaysSelect: undefined, 50 | hoursSelect: undefined, 51 | minutesSelect: undefined, 52 | }, 53 | { 54 | title: 'each monday', 55 | defaultValue: '* * * * 1', 56 | periodSelect: 'week', 57 | monthsSelect: undefined, 58 | monthDaysSelect: undefined, 59 | weekDaysSelect: 'MON', 60 | hoursSelect: 'every hour', 61 | minutesSelect: 'every minute', 62 | }, 63 | { 64 | title: 'every 2 hours', 65 | defaultValue: '* */2 * * *', 66 | periodSelect: 'day', 67 | monthsSelect: undefined, 68 | monthDaysSelect: undefined, 69 | weekDaysSelect: undefined, 70 | hoursSelect: 'every 2', 71 | minutesSelect: 'every minute', 72 | }, 73 | { 74 | title: 'valid cron values with simple range definition', 75 | defaultValue: '0 1 3-5 5 */2', 76 | periodSelect: 'year', 77 | monthsSelect: 'MAY', 78 | monthDaysSelect: '3-5', 79 | weekDaysSelect: 'every 2', 80 | hoursSelect: '1', 81 | minutesSelect: '0', 82 | }, 83 | { 84 | title: 'multiple minutes', 85 | defaultValue: '2,5,9,13,22 * * * *', 86 | periodSelect: 'hour', 87 | monthsSelect: undefined, 88 | monthDaysSelect: undefined, 89 | weekDaysSelect: undefined, 90 | hoursSelect: undefined, 91 | minutesSelect: '2,5,9,13,22', 92 | }, 93 | { 94 | title: 'a minute and an hour', 95 | defaultValue: '2 7 * * *', 96 | periodSelect: 'day', 97 | monthsSelect: undefined, 98 | monthDaysSelect: undefined, 99 | weekDaysSelect: undefined, 100 | hoursSelect: '7', 101 | minutesSelect: '2', 102 | }, 103 | { 104 | title: 'humanized value is allowed by default', 105 | defaultValue: '* * * MAY SUN', 106 | expectedValue: '* * * 5 0', 107 | periodSelect: 'year', 108 | monthsSelect: 'MAY', 109 | monthDaysSelect: 'day of the month', 110 | weekDaysSelect: 'SUN', 111 | hoursSelect: 'every hour', 112 | minutesSelect: 'every minute', 113 | }, 114 | { 115 | title: 'humanizeValue at false convert humanized value in cron', 116 | defaultValue: '* * * * MON-WED,sat', 117 | expectedValue: '* * * * 1-3,6', 118 | humanizeValue: false, 119 | periodSelect: 'week', 120 | monthsSelect: undefined, 121 | monthDaysSelect: undefined, 122 | weekDaysSelect: 'MON-WED,SAT', 123 | hoursSelect: 'every hour', 124 | minutesSelect: 'every minute', 125 | }, 126 | { 127 | title: 'humanizeValue at true keep humanized value in cron', 128 | defaultValue: '* * * * MON-WED,sat', 129 | expectedValue: '* * * * MON-WED,SAT', 130 | humanizeValue: true, 131 | periodSelect: 'week', 132 | monthsSelect: undefined, 133 | monthDaysSelect: undefined, 134 | weekDaysSelect: 'MON-WED,SAT', 135 | hoursSelect: 'every hour', 136 | minutesSelect: 'every minute', 137 | }, 138 | { 139 | title: 'humanized labels is allowed', 140 | defaultValue: '* * * * MON-WED,sat', 141 | expectedValue: '* * * * 1-3,6', 142 | humanizeLabels: true, 143 | periodSelect: 'week', 144 | monthsSelect: undefined, 145 | monthDaysSelect: undefined, 146 | weekDaysSelect: 'MON-WED,SAT', 147 | hoursSelect: 'every hour', 148 | minutesSelect: 'every minute', 149 | }, 150 | { 151 | title: 'humanized labels is not allowed', 152 | defaultValue: '* * * * MON-WED,sat', 153 | expectedValue: '* * * * 1-3,6', 154 | humanizeLabels: false, 155 | periodSelect: 'week', 156 | monthsSelect: undefined, 157 | monthDaysSelect: undefined, 158 | weekDaysSelect: '1-3,6', 159 | hoursSelect: 'every hour', 160 | minutesSelect: 'every minute', 161 | }, 162 | { 163 | title: 'leading zero is added when props is true', 164 | defaultValue: '1 3,18 6,23 * *', 165 | leadingZero: true, 166 | periodSelect: 'month', 167 | monthsSelect: undefined, 168 | monthDaysSelect: '06,23', 169 | weekDaysSelect: 'day of the week', 170 | hoursSelect: '03,18', 171 | minutesSelect: '01', 172 | }, 173 | { 174 | title: 'leading zero is added only for hours', 175 | defaultValue: '1 3,18 6,23 * *', 176 | leadingZero: ['hours'], 177 | periodSelect: 'month', 178 | monthsSelect: undefined, 179 | monthDaysSelect: '6,23', 180 | weekDaysSelect: 'day of the week', 181 | hoursSelect: '03,18', 182 | minutesSelect: '1', 183 | }, 184 | { 185 | title: 186 | 'leading zero is added for hours and minutes with clock format 24 hours', 187 | defaultValue: '1 3,18 6,23 * *', 188 | clockFormat: '24-hour-clock', 189 | periodSelect: 'month', 190 | monthsSelect: undefined, 191 | monthDaysSelect: '6,23', 192 | weekDaysSelect: 'day of the week', 193 | hoursSelect: '03,18', 194 | minutesSelect: '01', 195 | }, 196 | { 197 | title: 'AM and PM displayed with clock format 12 hours', 198 | defaultValue: '1 3,18 6,23 * *', 199 | clockFormat: '12-hour-clock', 200 | periodSelect: 'month', 201 | monthsSelect: undefined, 202 | monthDaysSelect: '6,23', 203 | weekDaysSelect: 'day of the week', 204 | hoursSelect: '3AM,6PM', 205 | minutesSelect: '1', 206 | }, 207 | { 208 | title: 'leading zero with AM and PM displayed with clock format 12 hours', 209 | defaultValue: '1 3,18 6,23 * *', 210 | leadingZero: true, 211 | clockFormat: '12-hour-clock', 212 | periodSelect: 'month', 213 | monthsSelect: undefined, 214 | monthDaysSelect: '06,23', 215 | weekDaysSelect: 'day of the week', 216 | hoursSelect: '03AM,06PM', 217 | minutesSelect: '01', 218 | }, 219 | { 220 | title: 'dropdowns config is allowed', 221 | defaultValue: '1 * * * MON-WED,sat', 222 | expectedValue: '1 * * * MON-WED,SAT', 223 | dropdownsConfig: { 224 | 'week-days': { 225 | humanizeLabels: true, 226 | humanizeValue: true, 227 | }, 228 | 'minutes': { 229 | leadingZero: true, 230 | }, 231 | }, 232 | periodSelect: 'week', 233 | monthsSelect: undefined, 234 | monthDaysSelect: undefined, 235 | weekDaysSelect: 'MON-WED,SAT', 236 | hoursSelect: 'every hour', 237 | minutesSelect: '01', 238 | }, 239 | { 240 | title: 'that default period can be override when default value is empty', 241 | defaultValue: '', 242 | defaultPeriod: 'year', 243 | periodSelect: 'year', 244 | monthsSelect: 'every month', 245 | monthDaysSelect: 'every day of the month', 246 | weekDaysSelect: 'every day of the week', 247 | hoursSelect: 'every hour', 248 | minutesSelect: 'every minute', 249 | }, 250 | { 251 | title: 'that default period is ignored when default value is not empty', 252 | defaultValue: '* * * * *', 253 | defaultPeriod: 'year', 254 | periodSelect: 'minute', 255 | monthsSelect: undefined, 256 | monthDaysSelect: undefined, 257 | weekDaysSelect: undefined, 258 | hoursSelect: undefined, 259 | minutesSelect: undefined, 260 | }, 261 | { 262 | title: 263 | 'that undefined for default value is considered like an empty string', 264 | defaultValue: undefined as unknown as string, 265 | periodSelect: 'day', 266 | monthsSelect: undefined, 267 | monthDaysSelect: undefined, 268 | weekDaysSelect: undefined, 269 | hoursSelect: 'every hour', 270 | minutesSelect: 'every minute', 271 | }, 272 | { 273 | title: 274 | 'that an empty string is allowed for default value with allowEmpty always', 275 | defaultValue: '', 276 | allowEmpty: 'always', 277 | periodSelect: 'day', 278 | monthsSelect: undefined, 279 | monthDaysSelect: undefined, 280 | weekDaysSelect: undefined, 281 | hoursSelect: 'every hour', 282 | minutesSelect: 'every minute', 283 | }, 284 | { 285 | title: 286 | 'that en empty string is allowed for default value with allowEmpty for-default-value', 287 | defaultValue: '', 288 | allowEmpty: 'for-default-value', 289 | periodSelect: 'day', 290 | monthsSelect: undefined, 291 | monthDaysSelect: undefined, 292 | weekDaysSelect: undefined, 293 | hoursSelect: 'every hour', 294 | minutesSelect: 'every minute', 295 | }, 296 | { 297 | title: 298 | 'that an empty string is not allowed for default value with allowEmpty never', 299 | defaultValue: '', 300 | allowEmpty: 'never', 301 | periodSelect: 'day', 302 | monthsSelect: undefined, 303 | monthDaysSelect: undefined, 304 | weekDaysSelect: undefined, 305 | hoursSelect: 'every hour', 306 | minutesSelect: 'every minute', 307 | error: defaultError, 308 | }, 309 | { 310 | title: 'wrong string value', 311 | defaultValue: 'wrong value', 312 | periodSelect: 'day', 313 | monthsSelect: undefined, 314 | monthDaysSelect: undefined, 315 | weekDaysSelect: undefined, 316 | hoursSelect: 'every hour', 317 | minutesSelect: 'every minute', 318 | error: defaultError, 319 | }, 320 | { 321 | title: 'wrong number value', 322 | defaultValue: 200 as unknown as string, 323 | periodSelect: 'day', 324 | monthsSelect: undefined, 325 | monthDaysSelect: undefined, 326 | weekDaysSelect: undefined, 327 | hoursSelect: 'every hour', 328 | minutesSelect: 'every minute', 329 | error: defaultError, 330 | }, 331 | { 332 | title: 'all units values filled set year period', 333 | defaultValue: '1 2 3 4 5', 334 | periodSelect: 'year', 335 | monthsSelect: 'APR', 336 | monthDaysSelect: '3', 337 | weekDaysSelect: 'FRI', 338 | hoursSelect: '2', 339 | minutesSelect: '1', 340 | }, 341 | { 342 | title: 'month days filled set month period', 343 | defaultValue: '* * 1 * *', 344 | periodSelect: 'month', 345 | monthsSelect: undefined, 346 | monthDaysSelect: '1', 347 | weekDaysSelect: 'day of the week', 348 | hoursSelect: 'every hour', 349 | minutesSelect: 'every minute', 350 | }, 351 | { 352 | title: 'that a range is allowed when valid', 353 | defaultValue: '1 2 3-9/3 4 5', 354 | periodSelect: 'year', 355 | monthsSelect: 'APR', 356 | monthDaysSelect: '3-9/3', 357 | weekDaysSelect: 'FRI', 358 | hoursSelect: '2', 359 | minutesSelect: '1', 360 | }, 361 | { 362 | title: 'that reboot shortcut is allowed when shortcuts is true', 363 | defaultValue: '@reboot', 364 | shortcuts: true, 365 | periodSelect: 'reboot', 366 | monthsSelect: undefined, 367 | monthDaysSelect: undefined, 368 | weekDaysSelect: undefined, 369 | hoursSelect: undefined, 370 | minutesSelect: undefined, 371 | }, 372 | { 373 | title: 'that reboot shortcut is not allowed when shortcuts is false', 374 | defaultValue: '@reboot', 375 | shortcuts: false, 376 | periodSelect: 'day', 377 | monthsSelect: undefined, 378 | monthDaysSelect: undefined, 379 | weekDaysSelect: undefined, 380 | hoursSelect: 'every hour', 381 | minutesSelect: 'every minute', 382 | error: defaultError, 383 | }, 384 | { 385 | title: 'that monthly shortcut is allowed when shortcuts is true', 386 | defaultValue: '@monthly', 387 | expectedValue: '0 0 1 * *', 388 | shortcuts: true, 389 | periodSelect: 'month', 390 | monthsSelect: undefined, 391 | monthDaysSelect: '1', 392 | weekDaysSelect: 'day of the week', 393 | hoursSelect: '0', 394 | minutesSelect: '0', 395 | }, 396 | { 397 | title: 398 | 'that monthly shortcut is not allowed when shortcuts only accept reboot', 399 | defaultValue: '@monthly', 400 | shortcuts: ['@reboot'], 401 | periodSelect: 'day', 402 | monthsSelect: undefined, 403 | monthDaysSelect: undefined, 404 | weekDaysSelect: undefined, 405 | hoursSelect: 'every hour', 406 | minutesSelect: 'every minute', 407 | error: defaultError, 408 | }, 409 | { 410 | title: 'that monthly shortcut is allowed when shortcuts accept @monthly', 411 | defaultValue: '@monthly', 412 | expectedValue: '0 0 1 * *', 413 | shortcuts: ['@monthly'], 414 | periodSelect: 'month', 415 | monthsSelect: undefined, 416 | monthDaysSelect: '1', 417 | weekDaysSelect: 'day of the week', 418 | hoursSelect: '0', 419 | minutesSelect: '0', 420 | }, 421 | { 422 | title: 'that a wrong shortcut is not allowed', 423 | defaultValue: '@wrongShortcut', 424 | shortcuts: ['@wrongShortcut'] as unknown as Shortcuts, 425 | periodSelect: 'day', 426 | monthsSelect: undefined, 427 | monthDaysSelect: undefined, 428 | weekDaysSelect: undefined, 429 | hoursSelect: 'every hour', 430 | minutesSelect: 'every minute', 431 | error: defaultError, 432 | }, 433 | { 434 | title: '7 for sunday converted to sunday', 435 | defaultValue: '* * * * 7', 436 | expectedValue: '* * * * 0', 437 | periodSelect: 'week', 438 | monthsSelect: undefined, 439 | monthDaysSelect: undefined, 440 | weekDaysSelect: 'SUN', 441 | hoursSelect: 'every hour', 442 | minutesSelect: 'every minute', 443 | }, 444 | { 445 | title: 'wrong range with double "/" throw an error', 446 | defaultValue: '2/2/2 * * * *', 447 | periodSelect: 'day', 448 | monthsSelect: undefined, 449 | monthDaysSelect: undefined, 450 | weekDaysSelect: undefined, 451 | hoursSelect: 'every hour', 452 | minutesSelect: 'every minute', 453 | error: defaultError, 454 | }, 455 | { 456 | title: 'full week days definition set every', 457 | defaultValue: '* * * * 0,1,2,3,4,5,6', 458 | expectedValue: '* * * * *', 459 | periodSelect: 'minute', 460 | monthsSelect: undefined, 461 | monthDaysSelect: undefined, 462 | weekDaysSelect: undefined, 463 | hoursSelect: undefined, 464 | minutesSelect: undefined, 465 | }, 466 | { 467 | title: 'that an out of range value too low is not allowed', 468 | defaultValue: '* * * * -1', 469 | periodSelect: 'day', 470 | monthsSelect: undefined, 471 | monthDaysSelect: undefined, 472 | weekDaysSelect: undefined, 473 | hoursSelect: 'every hour', 474 | minutesSelect: 'every minute', 475 | error: defaultError, 476 | }, 477 | { 478 | title: 'that an out of range value too big is not allowed', 479 | defaultValue: '* * * * 200', 480 | periodSelect: 'day', 481 | monthsSelect: undefined, 482 | monthDaysSelect: undefined, 483 | weekDaysSelect: undefined, 484 | hoursSelect: 'every hour', 485 | minutesSelect: 'every minute', 486 | error: defaultError, 487 | }, 488 | { 489 | title: 490 | 'that an out of range value too low for range first part is not allowed', 491 | defaultValue: '* * 0-3/4 * *', 492 | periodSelect: 'day', 493 | monthsSelect: undefined, 494 | monthDaysSelect: undefined, 495 | weekDaysSelect: undefined, 496 | hoursSelect: 'every hour', 497 | minutesSelect: 'every minute', 498 | error: defaultError, 499 | }, 500 | { 501 | title: 502 | 'that a range first value greater than second value is not allowed', 503 | defaultValue: '* * * * 5-2', 504 | periodSelect: 'day', 505 | monthsSelect: undefined, 506 | monthDaysSelect: undefined, 507 | weekDaysSelect: undefined, 508 | hoursSelect: 'every hour', 509 | minutesSelect: 'every minute', 510 | error: defaultError, 511 | }, 512 | { 513 | title: 'wrong range with more than one separator not allowed', 514 | defaultValue: '* * * * 5-2-2', 515 | periodSelect: 'day', 516 | monthsSelect: undefined, 517 | monthDaysSelect: undefined, 518 | weekDaysSelect: undefined, 519 | hoursSelect: 'every hour', 520 | minutesSelect: 'every minute', 521 | error: defaultError, 522 | }, 523 | { 524 | title: 'wrong range first part not allowed', 525 | defaultValue: '* * * * error/2', 526 | periodSelect: 'day', 527 | monthsSelect: undefined, 528 | monthDaysSelect: undefined, 529 | weekDaysSelect: undefined, 530 | hoursSelect: 'every hour', 531 | minutesSelect: 'every minute', 532 | error: defaultError, 533 | }, 534 | { 535 | title: 'wrong range second part not allowed', 536 | defaultValue: '* * * * 2/error', 537 | periodSelect: 'day', 538 | monthsSelect: undefined, 539 | monthDaysSelect: undefined, 540 | weekDaysSelect: undefined, 541 | hoursSelect: 'every hour', 542 | minutesSelect: 'every minute', 543 | error: defaultError, 544 | }, 545 | { 546 | title: 'that dropdowns are not visible if not allowed', 547 | defaultValue: '1 1 1 1 1', 548 | expectedPeriod: 'year', 549 | allowedDropdowns: [], 550 | periodSelect: undefined, 551 | monthsSelect: undefined, 552 | monthDaysSelect: undefined, 553 | weekDaysSelect: undefined, 554 | hoursSelect: undefined, 555 | minutesSelect: undefined, 556 | }, 557 | { 558 | title: 'custom multiple ranges with one interval', 559 | defaultValue: '* 2-10,19-23/2 * * *', 560 | expectedValue: '* 2-10,19,21,23 * * *', 561 | periodSelect: 'day', 562 | monthsSelect: undefined, 563 | monthDaysSelect: undefined, 564 | weekDaysSelect: undefined, 565 | hoursSelect: '2-10,19,21,23', 566 | minutesSelect: 'every minute', 567 | }, 568 | { 569 | title: 'custom multiple ranges with two intervals', 570 | defaultValue: '* 2-6/2,19-23/2 * * *', 571 | expectedValue: '* 2,4,6,19,21,23 * * *', 572 | periodSelect: 'day', 573 | monthsSelect: undefined, 574 | monthDaysSelect: undefined, 575 | weekDaysSelect: undefined, 576 | hoursSelect: '2,4,6,19,21,23', 577 | minutesSelect: 'every minute', 578 | }, 579 | { 580 | title: 'wrong end of value with text throw an error', 581 | defaultValue: '1-4/2crash * * * *', 582 | periodSelect: 'day', 583 | monthsSelect: undefined, 584 | monthDaysSelect: undefined, 585 | weekDaysSelect: undefined, 586 | hoursSelect: 'every hour', 587 | minutesSelect: 'every minute', 588 | error: defaultError, 589 | }, 590 | { 591 | title: 'wrong end of value with # throw an error', 592 | defaultValue: '* 1#3 * * *', 593 | periodSelect: 'day', 594 | monthsSelect: undefined, 595 | monthDaysSelect: undefined, 596 | weekDaysSelect: undefined, 597 | hoursSelect: 'every hour', 598 | minutesSelect: 'every minute', 599 | error: defaultError, 600 | }, 601 | { 602 | title: 'wrong end of value with # and week-day throw an error', 603 | defaultValue: '* * * * Sun#3', 604 | periodSelect: 'day', 605 | monthsSelect: undefined, 606 | monthDaysSelect: undefined, 607 | weekDaysSelect: undefined, 608 | hoursSelect: 'every hour', 609 | minutesSelect: 'every minute', 610 | error: defaultError, 611 | }, 612 | { 613 | title: 'wrong end of multiple values with # throw an error', 614 | defaultValue: '* * * 1,4#3 *', 615 | periodSelect: 'day', 616 | monthsSelect: undefined, 617 | monthDaysSelect: undefined, 618 | weekDaysSelect: undefined, 619 | hoursSelect: 'every hour', 620 | minutesSelect: 'every minute', 621 | error: defaultError, 622 | }, 623 | { 624 | title: 'wrong value with # in the middle throw an error', 625 | defaultValue: '* 1#2 * * *', 626 | periodSelect: 'day', 627 | monthsSelect: undefined, 628 | monthDaysSelect: undefined, 629 | weekDaysSelect: undefined, 630 | hoursSelect: 'every hour', 631 | minutesSelect: 'every minute', 632 | error: defaultError, 633 | }, 634 | { 635 | title: 'wrong value with text in the middle throw an error', 636 | defaultValue: '1crash5 * * * *', 637 | periodSelect: 'day', 638 | monthsSelect: undefined, 639 | monthDaysSelect: undefined, 640 | weekDaysSelect: undefined, 641 | hoursSelect: 'every hour', 642 | minutesSelect: 'every minute', 643 | error: defaultError, 644 | }, 645 | { 646 | title: 'wrong null value throw an error', 647 | defaultValue: 'null * * * *', 648 | periodSelect: 'day', 649 | monthsSelect: undefined, 650 | monthDaysSelect: undefined, 651 | weekDaysSelect: undefined, 652 | hoursSelect: 'every hour', 653 | minutesSelect: 'every minute', 654 | error: defaultError, 655 | }, 656 | { 657 | title: 'wrong empty value throw an error', 658 | defaultValue: '1,,2 * * * *', 659 | periodSelect: 'day', 660 | monthsSelect: undefined, 661 | monthDaysSelect: undefined, 662 | weekDaysSelect: undefined, 663 | hoursSelect: 'every hour', 664 | minutesSelect: 'every minute', 665 | error: defaultError, 666 | }, 667 | { 668 | title: 'wrong interval value throw an error', 669 | defaultValue: '1-/4 * * * *', 670 | periodSelect: 'day', 671 | monthsSelect: undefined, 672 | monthDaysSelect: undefined, 673 | weekDaysSelect: undefined, 674 | hoursSelect: 'every hour', 675 | minutesSelect: 'every minute', 676 | error: defaultError, 677 | }, 678 | { 679 | title: 'wrong false value throw an error', 680 | defaultValue: 'false * * * *', 681 | periodSelect: 'day', 682 | monthsSelect: undefined, 683 | monthDaysSelect: undefined, 684 | weekDaysSelect: undefined, 685 | hoursSelect: 'every hour', 686 | minutesSelect: 'every minute', 687 | error: defaultError, 688 | }, 689 | { 690 | title: 'wrong true value throw an error', 691 | defaultValue: 'true * * * *', 692 | periodSelect: 'day', 693 | monthsSelect: undefined, 694 | monthDaysSelect: undefined, 695 | weekDaysSelect: undefined, 696 | hoursSelect: 'every hour', 697 | minutesSelect: 'every minute', 698 | error: defaultError, 699 | }, 700 | { 701 | title: 'wrong number with e value throw an error', 702 | defaultValue: '2e1 * * * *', 703 | periodSelect: 'day', 704 | monthsSelect: undefined, 705 | monthDaysSelect: undefined, 706 | weekDaysSelect: undefined, 707 | hoursSelect: 'every hour', 708 | minutesSelect: 'every minute', 709 | error: defaultError, 710 | }, 711 | { 712 | title: 'wrong number with x value throw an error', 713 | defaultValue: '0xF * * * *', 714 | periodSelect: 'day', 715 | monthsSelect: undefined, 716 | monthDaysSelect: undefined, 717 | weekDaysSelect: undefined, 718 | hoursSelect: 'every hour', 719 | minutesSelect: 'every minute', 720 | error: defaultError, 721 | }, 722 | { 723 | title: 'leading 0 in value is accepted', 724 | defaultValue: '010 * * * *', 725 | expectedValue: '10 * * * *', 726 | periodSelect: 'hour', 727 | monthsSelect: undefined, 728 | monthDaysSelect: undefined, 729 | weekDaysSelect: undefined, 730 | hoursSelect: undefined, 731 | minutesSelect: '10', 732 | }, 733 | ] 734 | 735 | test.each(cases)( 736 | 'should check $title', 737 | ({ 738 | defaultValue, 739 | expectedValue, 740 | expectedPeriod, 741 | allowEmpty, 742 | shortcuts, 743 | humanizeLabels, 744 | humanizeValue, 745 | leadingZero, 746 | clockFormat, 747 | allowedDropdowns, 748 | defaultPeriod, 749 | periodSelect, 750 | monthsSelect, 751 | monthDaysSelect, 752 | weekDaysSelect, 753 | hoursSelect, 754 | minutesSelect, 755 | dropdownsConfig, 756 | error, 757 | }) => { 758 | const setValue = jest.fn() 759 | const onError = jest.fn() 760 | 761 | render( 762 | 776 | ) 777 | 778 | // 779 | // Check error management 780 | 781 | if (error) { 782 | expect(onError).toHaveBeenLastCalledWith(error) 783 | } else { 784 | expect(onError).toHaveBeenLastCalledWith(undefined) 785 | } 786 | 787 | // 788 | // Check value after Cron component validation 789 | 790 | if (defaultValue && !error) { 791 | const valueToCheck = expectedValue || defaultValue 792 | const selectedPeriodToCheck = expectedPeriod || periodSelect 793 | 794 | expect(setValue).toHaveBeenLastCalledWith(valueToCheck, { 795 | selectedPeriod: selectedPeriodToCheck, 796 | }) 797 | } 798 | 799 | // 800 | // Check period dropdown 801 | 802 | if (periodSelect) { 803 | expect(screen.getByTestId('select-period')).toBeVisible() 804 | expect(screen.getByTestId('select-period').textContent).toContain( 805 | periodSelect 806 | ) 807 | } else { 808 | expect(screen.queryByTestId(/select-period/i)).toBeNull() 809 | } 810 | 811 | // 812 | // Check months dropdown 813 | 814 | if (monthsSelect) { 815 | expect(screen.queryByTestId('custom-select-months')).toBeVisible() 816 | expect( 817 | screen.getByTestId('custom-select-months').textContent 818 | ).toContain(monthsSelect) 819 | } else { 820 | expect(screen.queryByTestId(/custom-select-months/i)).toBeNull() 821 | } 822 | 823 | // 824 | // Check month days dropdown 825 | 826 | if (monthDaysSelect) { 827 | expect(screen.queryByTestId('custom-select-month-days')).toBeVisible() 828 | expect( 829 | screen.getByTestId('custom-select-month-days').textContent 830 | ).toContain(monthDaysSelect) 831 | } else { 832 | expect(screen.queryByTestId(/custom-select-month-days/i)).toBeNull() 833 | } 834 | 835 | // 836 | // Check week days dropdown 837 | 838 | if (weekDaysSelect) { 839 | expect(screen.queryByTestId('custom-select-week-days')).toBeVisible() 840 | expect( 841 | screen.getByTestId('custom-select-week-days').textContent 842 | ).toContain(weekDaysSelect) 843 | } else { 844 | expect(screen.queryByTestId(/custom-select-week-days/i)).toBeNull() 845 | } 846 | 847 | // 848 | // Check hours dropdown 849 | 850 | if (hoursSelect) { 851 | expect(screen.queryByTestId('custom-select-hours')).toBeVisible() 852 | expect(screen.getByTestId('custom-select-hours').textContent).toContain( 853 | hoursSelect 854 | ) 855 | } else { 856 | expect(screen.queryByTestId(/custom-select-hours/i)).toBeNull() 857 | } 858 | 859 | // 860 | // Check minutes dropdown 861 | 862 | if (minutesSelect) { 863 | expect(screen.queryByTestId('custom-select-minutes')).toBeVisible() 864 | expect( 865 | screen.getByTestId('custom-select-minutes').textContent 866 | ).toContain(minutesSelect) 867 | } else { 868 | expect(screen.queryByTestId(/custom-select-minutes/i)).toBeNull() 869 | } 870 | } 871 | ) 872 | }) 873 | -------------------------------------------------------------------------------- /src/tests/Cron.updateValue.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from '@testing-library/react' 2 | import userEvent from '@testing-library/user-event' 3 | 4 | import Cron from '../Cron' 5 | 6 | describe('Cron update value test suite', () => { 7 | it("should check that it's possible to change the period from minute to year", async () => { 8 | const user = userEvent.setup() 9 | const value = '* * * * *' 10 | const setValue = jest.fn() 11 | 12 | render() 13 | 14 | // Open Period dropdown 15 | await waitFor(() => { 16 | user.click(screen.getByText('minute')) 17 | }) 18 | 19 | // Select year period 20 | await waitFor(() => { 21 | user.click(screen.getByText('year')) 22 | }) 23 | 24 | // Check dropdowns values 25 | await waitFor(() => { 26 | expect(screen.getByTestId('select-period').textContent).toContain('year') 27 | expect(screen.getByTestId('custom-select-months').textContent).toContain( 28 | 'every month' 29 | ) 30 | expect( 31 | screen.getByTestId('custom-select-month-days').textContent 32 | ).toContain('every day of the month') 33 | expect( 34 | screen.getByTestId('custom-select-week-days').textContent 35 | ).toContain('every day of the week') 36 | expect(screen.getByTestId('custom-select-hours').textContent).toContain( 37 | 'every hour' 38 | ) 39 | expect(screen.getByTestId('custom-select-minutes').textContent).toContain( 40 | 'every minute' 41 | ) 42 | }) 43 | }) 44 | 45 | it("should check that it's possible to select specific minutes", async () => { 46 | const user = userEvent.setup() 47 | const value = '1,4 * * * *' 48 | const setValue = jest.fn() 49 | 50 | render() 51 | 52 | // Open minute dropdown 53 | await waitFor(() => user.click(screen.getByText('1,4'))) 54 | 55 | // Select another minute value 56 | await waitFor(() => user.click(screen.getByText('59'))) 57 | 58 | // Check dropdowns values 59 | expect(await screen.findByText('1,4,59')).toBeVisible() 60 | }) 61 | 62 | it("should check that it's possible to select a periodicity with double click", async () => { 63 | const user = userEvent.setup() 64 | const value = '1,4 * * * *' 65 | const setValue = jest.fn() 66 | 67 | render() 68 | 69 | // Open minute dropdown 70 | await waitFor(() => { 71 | user.click(screen.getByText('1,4')) 72 | }) 73 | 74 | // Select another minute value 75 | await waitFor(() => { 76 | user.dblClick(screen.getByText('2')) 77 | }) 78 | 79 | // Check dropdowns values 80 | await waitFor(() => { 81 | expect(screen.getByTestId('custom-select-minutes').textContent).toContain( 82 | 'every 2' 83 | ) 84 | }) 85 | }) 86 | 87 | it("should check that it's possible to change a periodicity with double click", async () => { 88 | const user = userEvent.setup() 89 | const value = '*/2 * * * *' 90 | const setValue = jest.fn() 91 | 92 | render() 93 | 94 | // Open minute dropdown 95 | await waitFor(() => { 96 | user.click(screen.getByText('every 2')) 97 | }) 98 | 99 | // Select another minute value 100 | await waitFor(() => { 101 | user.dblClick(screen.getByText('4')) 102 | }) 103 | 104 | // Check dropdowns values 105 | await waitFor(() => { 106 | expect(screen.getByTestId('custom-select-minutes').textContent).toContain( 107 | 'every 4' 108 | ) 109 | }) 110 | }) 111 | 112 | it("should check that it's possible to clear cron value", async () => { 113 | const user = userEvent.setup() 114 | const value = '1 1 1 1 1' 115 | const setValue = jest.fn() 116 | 117 | render() 118 | 119 | // Clear cron value 120 | await waitFor(() => { 121 | user.click(screen.getByText('Clear')) 122 | }) 123 | 124 | // Check dropdowns values 125 | await waitFor(() => { 126 | expect(setValue).toHaveBeenNthCalledWith(2, '* * * * *', { 127 | selectedPeriod: 'year', 128 | }) 129 | }) 130 | }) 131 | 132 | it("should check that it's possible to clear cron value with empty", async () => { 133 | const user = userEvent.setup() 134 | const value = '1 1 1 1 1' 135 | const setValue = jest.fn() 136 | 137 | render() 138 | 139 | // Clear cron value 140 | await waitFor(() => { 141 | user.click(screen.getByText('Clear')) 142 | }) 143 | 144 | // Check dropdowns values 145 | await waitFor(() => { 146 | expect(setValue).toHaveBeenNthCalledWith(2, '', { 147 | selectedPeriod: 'year', 148 | }) 149 | }) 150 | }) 151 | 152 | it("should check that it's possible to clear cron value when @reboot is set", async () => { 153 | const user = userEvent.setup() 154 | const value = '@reboot' 155 | const setValue = jest.fn() 156 | 157 | render() 158 | 159 | // Clear cron value 160 | await waitFor(() => { 161 | user.click(screen.getByText('Clear')) 162 | }) 163 | 164 | // Check dropdowns values 165 | await waitFor(() => { 166 | expect(setValue).toHaveBeenNthCalledWith(2, '* * * * *', { 167 | selectedPeriod: 'day', 168 | }) 169 | }) 170 | }) 171 | 172 | it('should check that pressing clear setting an empty value throw an error if not allowed', async () => { 173 | const user = userEvent.setup() 174 | const value = '1 1 1 1 1' 175 | const setValue = jest.fn() 176 | const onError = jest.fn() 177 | 178 | render( 179 | 186 | ) 187 | 188 | // Clear cron value 189 | await waitFor(() => { 190 | user.click(screen.getByText('Clear')) 191 | }) 192 | 193 | // Check dropdowns values and error 194 | await waitFor(() => { 195 | expect(setValue).toHaveBeenNthCalledWith(2, '', { 196 | selectedPeriod: 'year', 197 | }) 198 | expect(onError).toHaveBeenNthCalledWith(3, { 199 | description: 'Invalid cron expression', 200 | type: 'invalid_cron', 201 | }) 202 | }) 203 | }) 204 | 205 | it("should check that pressing clear setting an empty value don't throw an error if not allowed", async () => { 206 | const user = userEvent.setup() 207 | const value = '1 1 1 1 1' 208 | const setValue = jest.fn() 209 | const onError = jest.fn() 210 | 211 | render( 212 | 219 | ) 220 | 221 | // Clear cron value 222 | await waitFor(() => { 223 | user.click(screen.getByText('Clear')) 224 | }) 225 | 226 | // Check dropdowns values and error 227 | await waitFor(() => { 228 | expect(setValue).toHaveBeenNthCalledWith(2, '', { 229 | selectedPeriod: 'year', 230 | }) 231 | expect(onError).toHaveBeenNthCalledWith(3, undefined) 232 | }) 233 | }) 234 | 235 | it("should check that it's not possible to update value when it's readOnly mode", async () => { 236 | const user = userEvent.setup() 237 | const value = '1,4 * * * *' 238 | const setValue = jest.fn() 239 | 240 | render() 241 | 242 | // Open minute dropdown 243 | await waitFor(() => user.click(screen.getByText('1,4'))) 244 | 245 | // Check dropdown is not visible 246 | await waitFor(() => { 247 | expect(screen.queryByText('59')).not.toBeInTheDocument() 248 | }) 249 | 250 | // Check dropdowns values still the sane 251 | expect(await screen.findByText('1,4')).toBeVisible() 252 | }) 253 | 254 | it("should check that it's not possible to update value when it's disabled mode", async () => { 255 | const user = userEvent.setup() 256 | const value = '1,4 * * * *' 257 | const setValue = jest.fn() 258 | 259 | render() 260 | 261 | // Open minute dropdown 262 | await waitFor(() => user.click(screen.getByText('1,4'))) 263 | 264 | // Check dropdown is not visible 265 | await waitFor(() => { 266 | expect(screen.queryByText('59')).not.toBeInTheDocument() 267 | }) 268 | 269 | // Check dropdowns values still the sane 270 | expect(await screen.findByText('1,4')).toBeVisible() 271 | }) 272 | 273 | it('should check that week-days and minutes options are filtered with dropdownConfig', async () => { 274 | const user = userEvent.setup() 275 | const value = '4,6 * * * 1' 276 | const setValue = jest.fn() 277 | 278 | render( 279 | Number(value) < 58, 286 | }, 287 | 'week-days': { 288 | // Remove sunday 289 | filterOption: ({ value }) => Number(value) !== 0, 290 | }, 291 | }} 292 | /> 293 | ) 294 | 295 | // Open minutes dropdown 296 | await waitFor(() => { 297 | user.click(screen.getByText('4,6')) 298 | }) 299 | 300 | // Check minutes 301 | await waitFor(() => { 302 | for (let i = 0; i < 60; i++) { 303 | if (i < 58) { 304 | expect(screen.getByText(i)).toBeVisible() 305 | } else { 306 | expect(screen.queryByText(i)).not.toBeInTheDocument() 307 | } 308 | } 309 | }) 310 | 311 | // Open week-days dropdown 312 | await waitFor(() => { 313 | user.click(screen.getByText('MON')) 314 | }) 315 | 316 | // Check days of the week 317 | await waitFor(() => { 318 | const days = [ 319 | 'Sunday', 320 | 'Monday', 321 | 'Tuesday', 322 | 'Wednesday', 323 | 'Thursday', 324 | 'Friday', 325 | 'Saturday', 326 | ] 327 | for (let i = 0; i < 7; i++) { 328 | if (i === 0) { 329 | expect(screen.queryByText(days[i])).not.toBeInTheDocument() 330 | } else { 331 | expect(screen.getByText(days[i])).toBeVisible() 332 | } 333 | } 334 | }) 335 | }) 336 | }) 337 | -------------------------------------------------------------------------------- /src/tests/__snapshots__/fields.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Fields matches the original snapshot 1`] = ` 4 | 5 |
8 | 9 | at 10 | 11 |
15 |
18 | 21 |
24 |
28 | 55 |
56 |
57 | 60 | every hour 61 | 62 |
63 |
64 | 90 |
91 |
92 |
93 | `; 94 | 95 | exports[`Fields matches the original snapshot 1`] = ` 96 | 97 |
100 | 101 | : 102 | 103 |
107 |
110 | 113 |
116 |
120 | 147 |
148 |
149 | 152 | every minute 153 | 154 |
155 |
156 | 182 |
183 |
184 |
185 | `; 186 | 187 | exports[`Fields matches the original snapshot 1`] = ` 188 | 189 |
192 | 193 | on 194 | 195 |
199 |
202 | 205 |
208 |
212 | 239 |
240 |
241 | 244 | every day of the month 245 | 246 |
247 |
248 | 274 |
275 |
276 |
277 | `; 278 | 279 | exports[`Fields matches the original snapshot 1`] = ` 280 | 281 |
284 | 285 | in 286 | 287 |
291 |
294 | 297 |
300 |
304 | 331 |
332 |
333 | 336 | every month 337 | 338 |
339 |
340 | 366 |
367 |
368 |
369 | `; 370 | 371 | exports[`Fields matches the original snapshot 1`] = ` 372 | 373 |
376 | 377 | Every 378 | 379 |
383 |
386 | 389 | 392 | 408 | 409 | 413 | year 414 | 415 | 416 |
417 | 443 |
444 |
445 |
446 | `; 447 | 448 | exports[`Fields matches the original snapshot 1`] = ` 449 | 450 |
453 | 454 | on 455 | 456 |
460 |
463 | 466 |
469 |
473 | 500 |
501 |
502 | 505 | every day of the week 506 | 507 |
508 |
509 | 535 |
536 |
537 |
538 | `; 539 | -------------------------------------------------------------------------------- /src/tests/fields.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | 3 | import Hours from '../fields/Hours' 4 | import Minutes from '../fields/Minutes' 5 | import MonthDays from '../fields/MonthDays' 6 | import Months from '../fields/Months' 7 | import Period from '../fields/Period' 8 | import WeekDays from '../fields/WeekDays' 9 | import { DEFAULT_LOCALE_EN } from '../locale' 10 | 11 | describe('Fields', () => { 12 | it(' matches the original snapshot', () => { 13 | const { asFragment } = render( 14 | value} 16 | locale={DEFAULT_LOCALE_EN} 17 | mode='multiple' 18 | period='month' 19 | disabled={false} 20 | readOnly={false} 21 | periodicityOnDoubleClick 22 | leadingZero 23 | /> 24 | ) 25 | 26 | expect(asFragment()).toMatchSnapshot() 27 | }) 28 | 29 | it(' matches the original snapshot', () => { 30 | const { asFragment } = render( 31 | value} 33 | locale={DEFAULT_LOCALE_EN} 34 | mode='multiple' 35 | period='month' 36 | disabled={false} 37 | readOnly={false} 38 | periodicityOnDoubleClick 39 | leadingZero 40 | /> 41 | ) 42 | 43 | expect(asFragment()).toMatchSnapshot() 44 | }) 45 | 46 | it(' matches the original snapshot', () => { 47 | const { asFragment } = render( 48 | value} 50 | locale={DEFAULT_LOCALE_EN} 51 | mode='multiple' 52 | period='month' 53 | disabled={false} 54 | readOnly={false} 55 | periodicityOnDoubleClick 56 | leadingZero 57 | /> 58 | ) 59 | 60 | expect(asFragment()).toMatchSnapshot() 61 | }) 62 | 63 | it(' matches the original snapshot', () => { 64 | const { asFragment } = render( 65 | value} 67 | locale={DEFAULT_LOCALE_EN} 68 | mode='multiple' 69 | period='year' 70 | disabled={false} 71 | readOnly={false} 72 | periodicityOnDoubleClick 73 | humanizeLabels 74 | /> 75 | ) 76 | 77 | expect(asFragment()).toMatchSnapshot() 78 | }) 79 | 80 | it(' matches the original snapshot', () => { 81 | const { asFragment } = render( 82 | value} 84 | locale={DEFAULT_LOCALE_EN} 85 | disabled={false} 86 | readOnly={false} 87 | value='year' 88 | allowedPeriods={[ 89 | 'minute', 90 | 'hour', 91 | 'day', 92 | 'week', 93 | 'month', 94 | 'year', 95 | 'reboot', 96 | ]} 97 | shortcuts 98 | /> 99 | ) 100 | 101 | expect(asFragment()).toMatchSnapshot() 102 | }) 103 | 104 | it(' matches the original snapshot', () => { 105 | const { asFragment } = render( 106 | value} 108 | locale={DEFAULT_LOCALE_EN} 109 | disabled={false} 110 | readOnly={false} 111 | mode='multiple' 112 | period='week' 113 | humanizeLabels 114 | periodicityOnDoubleClick 115 | /> 116 | ) 117 | 118 | expect(asFragment()).toMatchSnapshot() 119 | }) 120 | }) 121 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ButtonProps, SelectProps } from 'antd' 2 | import { Dispatch, SetStateAction } from 'react' 3 | 4 | // External props 5 | 6 | export interface CronProps { 7 | /** 8 | * Cron value, the component is by design a controlled component. 9 | * The first value will be the default value. 10 | * 11 | * required 12 | */ 13 | value: string 14 | 15 | /** 16 | * Set the cron value, similar to onChange. 17 | * The naming tells you that you have to set the value by yourself. 18 | * 19 | * required 20 | */ 21 | setValue: SetValue 22 | 23 | /** 24 | * Set the container className and used as a prefix for other selectors. 25 | * Available selectors: https://xrutayisire.github.io/react-js-cron/?path=/story/reactjs-cron--custom-style 26 | */ 27 | className?: string 28 | 29 | /** 30 | * Humanize the labels in the cron component, SUN-SAT and JAN-DEC. 31 | * 32 | * Default: true 33 | */ 34 | humanizeLabels?: boolean 35 | 36 | /** 37 | * Humanize the value, SUN-SAT and JAN-DEC. 38 | * 39 | * Default: false 40 | */ 41 | humanizeValue?: boolean 42 | 43 | /** 44 | * Add a "0" before numbers lower than 10. 45 | * 46 | * Default: false 47 | */ 48 | leadingZero?: LeadingZero 49 | 50 | /** 51 | * Define the default period when the default value is empty. 52 | * 53 | * Default: 'day' 54 | */ 55 | defaultPeriod?: PeriodType 56 | 57 | /** 58 | * Disable the cron component. 59 | * 60 | * Default: false 61 | */ 62 | disabled?: boolean 63 | 64 | /** 65 | * Make the cron component read-only. 66 | * 67 | * Default: false 68 | */ 69 | readOnly?: boolean 70 | 71 | /** 72 | * Show clear button for each dropdown. 73 | * 74 | * Default: true 75 | */ 76 | allowClear?: boolean 77 | 78 | /** 79 | * Define if empty should trigger an error. 80 | * 81 | * Default: 'for-default-value' 82 | */ 83 | allowEmpty?: AllowEmpty 84 | 85 | /** 86 | * Support cron shortcuts. 87 | * 88 | * Default: ['@yearly', '@annually', '@monthly', '@weekly', '@daily', '@midnight', '@hourly'] 89 | */ 90 | shortcuts?: Shortcuts 91 | 92 | /** 93 | * Define the clock format. 94 | * 95 | * Default: undefined 96 | */ 97 | clockFormat?: ClockFormat 98 | 99 | /** 100 | * Display the clear button. 101 | * 102 | * Default: true 103 | */ 104 | clearButton?: boolean 105 | 106 | /** 107 | * antd button props to customize the clear button. 108 | */ 109 | clearButtonProps?: ClearButtonProps 110 | 111 | /** 112 | * Define the clear button action. 113 | * 114 | * Default: 'fill-with-every' 115 | */ 116 | clearButtonAction?: ClearButtonAction 117 | 118 | /** 119 | * Display error style (red border and background). 120 | * 121 | * Display: true 122 | */ 123 | displayError?: boolean 124 | 125 | /** 126 | * Triggered when the cron component detects an error with the value. 127 | */ 128 | onError?: OnError 129 | 130 | /** 131 | * Define if a double click on a dropdown option should automatically 132 | * select / unselect a periodicity. 133 | * 134 | * Default: true 135 | */ 136 | periodicityOnDoubleClick?: boolean 137 | 138 | /** 139 | * Define if it's possible to select only one or multiple values for each dropdowns. 140 | * 141 | * Even in single mode, if you want to disable the double click on a dropdown option that 142 | * automatically select / unselect a periodicity, set 'periodicityOnDoubleClick' 143 | * prop at false. 144 | * 145 | * When single mode is active and 'periodicityOnDoubleClick' is false, 146 | * each dropdown will automatically close after selecting a value 147 | * 148 | * Default: 'multiple' 149 | */ 150 | mode?: Mode 151 | 152 | /** 153 | * Define which dropdowns need to be displayed. 154 | * 155 | * Default: ['period', 'months', 'month-days', 'week-days', 'hours', 'minutes'] 156 | */ 157 | allowedDropdowns?: CronType[] 158 | 159 | /** 160 | * Define the list of periods available. 161 | * 162 | * Default: ['year', 'month', 'week', 'day', 'hour', 'minute', 'reboot'] 163 | */ 164 | allowedPeriods?: PeriodType[] 165 | 166 | /** 167 | * Define specific configuration that is used for each dropdown specifically. 168 | * Configuring a dropdown will override any global configuration for the same property. 169 | * 170 | * Configuration available: 171 | * 172 | * // See global configuration 173 | * // For 'months' and 'week-days' 174 | * humanizeLabels?: boolean 175 | * 176 | * // See global configuration 177 | * // For 'months' and 'week-days' 178 | * humanizeValue?: boolean 179 | * 180 | * // See global configuration 181 | * // For 'month-days', 'hours' and 'minutes' 182 | * leadingZero?: boolean 183 | * 184 | * // See global configuration 185 | * For 'period', 'months', 'month-days', 'week-days', 'hours' and 'minutes' 186 | * disabled?: boolean 187 | * 188 | * // See global configuration 189 | * For 'period', 'months', 'month-days', 'week-days', 'hours' and 'minutes' 190 | * readOnly?: boolean 191 | * 192 | * // See global configuration 193 | * // For 'period', 'months', 'month-days', 'week-days', 'hours' and 'minutes' 194 | * allowClear?: boolean 195 | * 196 | * // See global configuration 197 | * // For 'months', 'month-days', 'week-days', 'hours' and 'minutes' 198 | * periodicityOnDoubleClick?: boolean 199 | * 200 | * // See global configuration 201 | * // For 'months', 'month-days', 'week-days', 'hours' and 'minutes' 202 | * mode?: Mode 203 | * 204 | * // The function will receive one argument, an object with value and label. 205 | * // If the function returns true, the option will be included in the filtered set. 206 | * // Otherwise, it will be excluded. 207 | * // For 'months', 'month-days', 'week-days', 'hours' and 'minutes' 208 | * filterOption?: FilterOption 209 | * 210 | * Default: undefined 211 | */ 212 | dropdownsConfig?: DropdownsConfig 213 | 214 | /** 215 | * Change the component language. 216 | * Can also be used to remove prefix and suffix. 217 | * 218 | * When setting 'humanizeLabels' you can change the language of the 219 | * alternative labels with 'altWeekDays' and 'altMonths'. 220 | * 221 | * The order of the 'locale' properties 'weekDays', 'months', 'altMonths' 222 | * and 'altWeekDays' is important! The index will be used as value. 223 | * 224 | * Default './src/locale.ts' 225 | */ 226 | locale?: Locale 227 | 228 | /** 229 | * Define the container for the dropdowns. 230 | * By default, the dropdowns will be rendered in the body. 231 | * This is useful when you want to render the dropdowns in a specific 232 | * container, for example, when using a modal or a specific layout. 233 | */ 234 | getPopupContainer?: () => HTMLElement 235 | } 236 | export interface Locale { 237 | everyText?: string 238 | emptyMonths?: string 239 | emptyMonthDays?: string 240 | emptyMonthDaysShort?: string 241 | emptyWeekDays?: string 242 | emptyWeekDaysShort?: string 243 | emptyHours?: string 244 | emptyMinutes?: string 245 | emptyMinutesForHourPeriod?: string 246 | yearOption?: string 247 | monthOption?: string 248 | weekOption?: string 249 | dayOption?: string 250 | hourOption?: string 251 | minuteOption?: string 252 | rebootOption?: string 253 | prefixPeriod?: string 254 | prefixMonths?: string 255 | prefixMonthDays?: string 256 | prefixWeekDays?: string 257 | prefixWeekDaysForMonthAndYearPeriod?: string 258 | prefixHours?: string 259 | prefixMinutes?: string 260 | prefixMinutesForHourPeriod?: string 261 | suffixMinutesForHourPeriod?: string 262 | errorInvalidCron?: string 263 | clearButtonText?: string 264 | weekDays?: string[] 265 | months?: string[] 266 | altWeekDays?: string[] 267 | altMonths?: string[] 268 | } 269 | export type SetValueFunction = ( 270 | value: string, 271 | extra: SetValueFunctionExtra 272 | ) => void 273 | export interface SetValueFunctionExtra { 274 | selectedPeriod: PeriodType 275 | } 276 | export type SetValue = SetValueFunction | Dispatch> 277 | export type CronError = 278 | | { 279 | type: 'invalid_cron' 280 | description: string 281 | } 282 | | undefined 283 | export type OnErrorFunction = (error: CronError) => void 284 | export type OnError = 285 | | OnErrorFunction 286 | | Dispatch> 287 | | undefined 288 | export interface ClearButtonProps extends Omit {} 289 | export type ClearButtonAction = 'empty' | 'fill-with-every' 290 | export type PeriodType = 291 | | 'year' 292 | | 'month' 293 | | 'week' 294 | | 'day' 295 | | 'hour' 296 | | 'minute' 297 | | 'reboot' 298 | export type AllowEmpty = 'always' | 'never' | 'for-default-value' 299 | export type CronType = 300 | | 'period' 301 | | 'months' 302 | | 'month-days' 303 | | 'week-days' 304 | | 'hours' 305 | | 'minutes' 306 | export type LeadingZeroType = 'month-days' | 'hours' | 'minutes' 307 | export type LeadingZero = boolean | LeadingZeroType[] 308 | export type ClockFormat = '24-hour-clock' | '12-hour-clock' 309 | export type ShortcutsType = 310 | | '@yearly' 311 | | '@annually' 312 | | '@monthly' 313 | | '@weekly' 314 | | '@daily' 315 | | '@midnight' 316 | | '@hourly' 317 | | '@reboot' 318 | export type Shortcuts = boolean | ShortcutsType[] 319 | export type Mode = 'multiple' | 'single' 320 | export type DropdownConfig = { 321 | humanizeLabels?: boolean 322 | humanizeValue?: boolean 323 | leadingZero?: boolean 324 | disabled?: boolean 325 | readOnly?: boolean 326 | allowClear?: boolean 327 | periodicityOnDoubleClick?: boolean 328 | mode?: Mode 329 | filterOption?: FilterOption 330 | } 331 | export type DropdownsConfig = { 332 | 'period'?: Pick 333 | 'months'?: Omit 334 | 'month-days'?: Omit 335 | 'week-days'?: Omit 336 | 'hours'?: Omit 337 | 'minutes'?: Omit 338 | } 339 | 340 | // Internal props 341 | 342 | export interface FieldProps { 343 | value?: number[] 344 | setValue: SetValueNumbersOrUndefined 345 | locale: Locale 346 | className?: string 347 | disabled: boolean 348 | readOnly: boolean 349 | period: PeriodType 350 | periodicityOnDoubleClick: boolean 351 | mode: Mode 352 | allowClear?: boolean 353 | filterOption?: FilterOption 354 | getPopupContainer?: () => HTMLElement 355 | } 356 | export interface PeriodProps 357 | extends Omit< 358 | FieldProps, 359 | | 'value' 360 | | 'setValue' 361 | | 'period' 362 | | 'periodicityOnDoubleClick' 363 | | 'mode' 364 | | 'filterOption' 365 | > { 366 | value: PeriodType 367 | setValue: SetValuePeriod 368 | shortcuts: Shortcuts 369 | allowedPeriods: PeriodType[] 370 | getPopupContainer?: () => HTMLElement 371 | } 372 | export interface MonthsProps extends FieldProps { 373 | humanizeLabels: boolean 374 | } 375 | export interface MonthDaysProps extends FieldProps { 376 | weekDays?: number[] 377 | leadingZero: LeadingZero 378 | } 379 | export interface WeekDaysProps extends FieldProps { 380 | humanizeLabels: boolean 381 | monthDays?: number[] 382 | } 383 | export interface HoursProps extends FieldProps { 384 | leadingZero: LeadingZero 385 | clockFormat?: ClockFormat 386 | } 387 | export interface MinutesProps extends FieldProps { 388 | leadingZero: LeadingZero 389 | clockFormat?: ClockFormat 390 | } 391 | export interface CustomSelectProps 392 | extends Omit< 393 | SelectProps, 394 | | 'mode' 395 | | 'tokenSeparators' 396 | | 'virtual' 397 | | 'onClick' 398 | | 'onBlur' 399 | | 'tagRender' 400 | | 'dropdownRender' 401 | | 'showSearch' 402 | | 'suffixIcon' 403 | | 'onChange' 404 | | 'dropdownMatchSelectWidth' 405 | | 'options' 406 | | 'onSelect' 407 | | 'onDeselect' 408 | | 'filterOption' 409 | > { 410 | grid?: boolean 411 | setValue: SetValueNumbersOrUndefined 412 | optionsList?: string[] 413 | locale: Locale 414 | value?: number[] 415 | humanizeLabels?: boolean 416 | disabled: boolean 417 | readOnly: boolean 418 | leadingZero?: LeadingZero 419 | clockFormat?: ClockFormat 420 | period: PeriodType 421 | unit: Unit 422 | periodicityOnDoubleClick: boolean 423 | mode: Mode 424 | filterOption?: FilterOption 425 | getPopupContainer?: () => HTMLElement 426 | } 427 | export type SetValueNumbersOrUndefined = Dispatch< 428 | SetStateAction 429 | > 430 | export type SetValuePeriod = Dispatch> 431 | export type SetInternalError = Dispatch> 432 | export interface DefaultLocale { 433 | everyText: string 434 | emptyMonths: string 435 | emptyMonthDays: string 436 | emptyMonthDaysShort: string 437 | emptyWeekDays: string 438 | emptyWeekDaysShort: string 439 | emptyHours: string 440 | emptyMinutes: string 441 | emptyMinutesForHourPeriod: string 442 | yearOption: string 443 | monthOption: string 444 | weekOption: string 445 | dayOption: string 446 | hourOption: string 447 | minuteOption: string 448 | rebootOption: string 449 | prefixPeriod: string 450 | prefixMonths: string 451 | prefixMonthDays: string 452 | prefixWeekDays: string 453 | prefixWeekDaysForMonthAndYearPeriod: string 454 | prefixHours: string 455 | prefixMinutes: string 456 | prefixMinutesForHourPeriod: string 457 | suffixMinutesForHourPeriod: string 458 | errorInvalidCron: string 459 | clearButtonText: string 460 | weekDays: string[] 461 | months: string[] 462 | altWeekDays: string[] 463 | altMonths: string[] 464 | } 465 | export interface Classes { 466 | [key: string]: boolean 467 | } 468 | export interface ShortcutsValues { 469 | name: ShortcutsType 470 | value: string 471 | } 472 | export interface Unit { 473 | type: CronType 474 | min: number 475 | max: number 476 | total: number 477 | alt?: string[] 478 | } 479 | export interface Clicks { 480 | time: number 481 | value: number 482 | } 483 | 484 | export type FilterOption = ({ 485 | value, 486 | label, 487 | }: { 488 | value: string 489 | label: string 490 | }) => boolean 491 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | import { DEFAULT_LOCALE_EN } from './locale' 4 | import { Classes, Locale, OnError } from './types' 5 | 6 | /** 7 | * Creates an array of integers from start to end, inclusive 8 | */ 9 | export function range(start: number, end: number) { 10 | const array: number[] = [] 11 | 12 | for (let i = start; i <= end; i++) { 13 | array.push(i) 14 | } 15 | 16 | return array 17 | } 18 | 19 | /** 20 | * Sorts an array of numbers 21 | */ 22 | export function sort(array: number[]) { 23 | array.sort(function (a, b) { 24 | return a - b 25 | }) 26 | 27 | return array 28 | } 29 | 30 | /** 31 | * Removes duplicate entries from an array 32 | */ 33 | export function dedup(array: number[]) { 34 | const result: number[] = [] 35 | 36 | array.forEach(function (i) { 37 | if (result.indexOf(i) < 0) { 38 | result.push(i) 39 | } 40 | }) 41 | 42 | return result 43 | } 44 | 45 | /** 46 | * Simple classNames util function to prevent adding external library 'classnames' 47 | */ 48 | export function classNames(classes: Classes) { 49 | return Object.entries(classes) 50 | .filter(([key, value]) => key && value) 51 | .map(([key]) => key) 52 | .join(' ') 53 | } 54 | 55 | /** 56 | * Handle onError prop to set the error 57 | */ 58 | export function setError(onError: OnError, locale: Locale) { 59 | onError && 60 | onError({ 61 | type: 'invalid_cron', 62 | description: 63 | locale.errorInvalidCron || DEFAULT_LOCALE_EN.errorInvalidCron, 64 | }) 65 | } 66 | 67 | /** 68 | * React useEffect hook to return the previous value 69 | */ 70 | export function usePrevious(value: any) { 71 | const ref = useRef(value) 72 | 73 | useEffect(() => { 74 | ref.current = value 75 | }, [value]) 76 | 77 | return ref.current 78 | } 79 | 80 | /** 81 | * Convert a string to number but fail if not valid for cron 82 | */ 83 | export function convertStringToNumber(str: string) { 84 | const parseIntValue = parseInt(str, 10) 85 | const numberValue = Number(str) 86 | 87 | return parseIntValue === numberValue ? numberValue : NaN 88 | } 89 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationDir": "types", 5 | "emitDeclarationOnly": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "lib": ["dom", "dom.iterable", "esnext"], 9 | "module": "ESNext", 10 | "moduleResolution": "node", 11 | "noImplicitAny": true, 12 | "outDir": "dist", 13 | "removeComments": true, 14 | "rootDir": "src", 15 | "strictNullChecks": true, 16 | "target": "es5" 17 | }, 18 | "include": ["./src/**/*"], 19 | "exclude": ["node_modules"] 20 | } 21 | --------------------------------------------------------------------------------