Skip to content

Commit 1b7ba7c

Browse files
xrutayisireclaude
andauthored
feat!: treat * * * * * as empty and add per-dropdown allowEmpty in dropdownsConfig (#89)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d069ff0 commit 1b7ba7c

File tree

6 files changed

+383
-10
lines changed

6 files changed

+383
-10
lines changed

README.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ react-js-cron is written in TypeScript with complete definitions
3939

4040
Be sure that you have these dependencies on your project:
4141

42-
- react (>=17.0.0)
43-
- antd (>=5.8.0)
42+
- react (>=18.0.0)
43+
- antd (>=6.0.0)
4444

4545
```bash
4646
# NPM
@@ -194,7 +194,9 @@ CronProps {
194194
leadingZero?: boolean | ['month-days', 'hours', 'minutes']
195195
196196
/**
197-
* Define the default period when the default value is empty.
197+
* Define the default period when the value is empty.
198+
* When set and the cron value is ambiguous (compatible with multiple periods),
199+
* this period will be preferred over the auto-detected one.
198200
*
199201
* Default: 'day'
200202
*/
@@ -357,6 +359,10 @@ CronProps {
357359
*
358360
* // See global configuration
359361
* // For 'months', 'month-days', 'week-days', 'hours' and 'minutes'
362+
* allowEmpty?: 'always' | 'never' | 'for-default-value'
363+
*
364+
* // See global configuration
365+
* // For 'months', 'month-days', 'week-days', 'hours' and 'minutes'
360366
* periodicityOnDoubleClick?: boolean
361367
*
362368
* // See global configuration
@@ -383,6 +389,7 @@ CronProps {
383389
disabled?: boolean
384390
readOnly?: boolean
385391
allowClear?: boolean
392+
allowEmpty?: 'always' | 'never' | 'for-default-value'
386393
periodicityOnDoubleClick?: boolean
387394
mode?: 'multiple' | 'single'
388395
filterOption?: ({
@@ -398,6 +405,7 @@ CronProps {
398405
disabled?: boolean
399406
readOnly?: boolean
400407
allowClear?: boolean
408+
allowEmpty?: 'always' | 'never' | 'for-default-value'
401409
periodicityOnDoubleClick?: boolean
402410
mode?: 'multiple' | 'single'
403411
filterOption?: ({
@@ -414,6 +422,7 @@ CronProps {
414422
disabled?: boolean
415423
readOnly?: boolean
416424
allowClear?: boolean
425+
allowEmpty?: 'always' | 'never' | 'for-default-value'
417426
periodicityOnDoubleClick?: boolean
418427
mode?: 'multiple' | 'single'
419428
filterOption?: ({
@@ -429,6 +438,7 @@ CronProps {
429438
disabled?: boolean
430439
readOnly?: boolean
431440
allowClear?: boolean
441+
allowEmpty?: 'always' | 'never' | 'for-default-value'
432442
periodicityOnDoubleClick?: boolean
433443
mode?: 'multiple' | 'single'
434444
filterOption?: ({
@@ -444,6 +454,7 @@ CronProps {
444454
disabled?: boolean
445455
readOnly?: boolean
446456
allowClear?: boolean
457+
allowEmpty?: 'always' | 'never' | 'for-default-value'
447458
periodicityOnDoubleClick?: boolean
448459
mode?: 'multiple' | 'single'
449460
filterOption?: ({
@@ -495,6 +506,7 @@ CronProps {
495506
prefixMinutesForHourPeriod?: string
496507
suffixMinutesForHourPeriod?: string
497508
errorInvalidCron?: string
509+
clearButtonText?: string
498510
weekDays?: string[]
499511
months?: string[]
500512
altWeekDays?: string[]

src/Cron.tsx

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { Button } from 'antd'
22
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
33

4-
import { getCronStringFromValues, setValuesFromCronString } from './converter'
4+
import {
5+
getCronStringFromValues,
6+
hasDropdownAllowEmptyError,
7+
setValuesFromCronString,
8+
} from './converter'
59
import Hours from './fields/Hours'
610
import Minutes from './fields/Minutes'
711
import MonthDays from './fields/MonthDays'
@@ -64,6 +68,8 @@ export default function Cron(props: CronProps) {
6468
getPopupContainer,
6569
} = props
6670
const internalValueRef = useRef<string>(value)
71+
const initialValueRef = useRef<string>(value)
72+
const firstRenderRef = useRef(true)
6773
const effectiveDefaultPeriod = defaultPeriod ?? 'day'
6874
const defaultPeriodRef = useRef<PeriodType>(effectiveDefaultPeriod)
6975
const [period, setPeriod] = useState<PeriodType | undefined>()
@@ -76,6 +82,7 @@ export default function Cron(props: CronProps) {
7682
const [valueCleared, setValueCleared] = useState<boolean>(false)
7783
const previousValueCleared = usePrevious(valueCleared)
7884
const localeJSON = JSON.stringify(locale)
85+
const shortcutsJSON = JSON.stringify(shortcuts)
7986
const dropdownsConfigJSON = JSON.stringify(dropdownsConfig)
8087

8188
useEffect(
@@ -97,6 +104,7 @@ export default function Cron(props: CronProps) {
97104
setPeriod,
98105
defaultPeriod,
99106
allowedPeriods,
107+
dropdownsConfig,
100108
)
101109
},
102110
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -123,11 +131,12 @@ export default function Cron(props: CronProps) {
123131
setPeriod,
124132
defaultPeriod,
125133
allowedPeriods,
134+
dropdownsConfig,
126135
)
127136
}
128137
},
129138
// eslint-disable-next-line react-hooks/exhaustive-deps
130-
[value, internalValueRef, localeJSON, allowEmpty, shortcuts],
139+
[value, internalValueRef, localeJSON, allowEmpty, shortcutsJSON],
131140
)
132141

133142
useEffect(
@@ -154,10 +163,42 @@ export default function Cron(props: CronProps) {
154163
setValue(cron, { selectedPeriod })
155164
internalValueRef.current = cron
156165

157-
if (onError) {
158-
onError(undefined)
166+
if (firstRenderRef.current) {
167+
initialValueRef.current = cron
168+
firstRenderRef.current = false
169+
}
170+
171+
const isEmptyCron = cron === '* * * * *'
172+
const isInitialValue = cron === initialValueRef.current
173+
const hasGlobalEmptyError =
174+
isEmptyCron &&
175+
(allowEmpty === 'never' ||
176+
(allowEmpty === 'for-default-value' && !isInitialValue))
177+
178+
const hasPerDropdownError =
179+
!hasGlobalEmptyError &&
180+
hasDropdownAllowEmptyError(
181+
[
182+
minutes ?? [],
183+
hours ?? [],
184+
monthDays ?? [],
185+
months ?? [],
186+
weekDays ?? [],
187+
],
188+
selectedPeriod,
189+
isInitialValue,
190+
dropdownsConfig,
191+
)
192+
193+
if (hasGlobalEmptyError || hasPerDropdownError) {
194+
setInternalError(true)
195+
setError(onError, locale)
196+
} else {
197+
if (onError) {
198+
onError(undefined)
199+
}
200+
setInternalError(false)
159201
}
160-
setInternalError(false)
161202
} else if (valueCleared) {
162203
setValueCleared(false)
163204
}
@@ -172,6 +213,7 @@ export default function Cron(props: CronProps) {
172213
minutes,
173214
humanizeValue,
174215
valueCleared,
216+
allowEmpty,
175217
dropdownsConfigJSON,
176218
],
177219
)
@@ -215,7 +257,23 @@ export default function Cron(props: CronProps) {
215257

216258
setValueCleared(true)
217259

218-
if (allowEmpty === 'never' && clearButtonAction === 'empty') {
260+
const isEmptyCron = newValue === '* * * * *'
261+
const isInitialValue = newValue === initialValueRef.current
262+
const hasEmptyError =
263+
(allowEmpty === 'never' &&
264+
(clearButtonAction === 'empty' || isEmptyCron)) ||
265+
(allowEmpty === 'for-default-value' && isEmptyCron && !isInitialValue)
266+
const hasPerDropdownError =
267+
!hasEmptyError &&
268+
isEmptyCron &&
269+
hasDropdownAllowEmptyError(
270+
[[], [], [], [], []],
271+
newPeriod,
272+
isInitialValue,
273+
dropdownsConfig,
274+
)
275+
276+
if (hasEmptyError || hasPerDropdownError) {
219277
setInternalError(true)
220278
setError(onError, locale)
221279
} else {
@@ -226,7 +284,15 @@ export default function Cron(props: CronProps) {
226284
}
227285
},
228286
// eslint-disable-next-line react-hooks/exhaustive-deps
229-
[period, setValue, onError, clearButtonAction],
287+
[
288+
period,
289+
setValue,
290+
onError,
291+
clearButtonAction,
292+
allowEmpty,
293+
localeJSON,
294+
dropdownsConfigJSON,
295+
],
230296
)
231297

232298
const internalClassName = useMemo(

src/converter.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export function setValuesFromCronString(
3838
setPeriod: SetValuePeriod,
3939
defaultPeriod?: PeriodType,
4040
allowedPeriods?: PeriodType[],
41+
dropdownsConfig?: DropdownsConfig,
4142
) {
4243
if (onError) {
4344
onError(undefined)
@@ -94,6 +95,27 @@ export function setValuesFromCronString(
9495
setMonthDays(cronParts[2])
9596
setMonths(cronParts[3])
9697
setWeekDays(cronParts[4])
98+
99+
// Check if all parts are empty (= * * * * *)
100+
const allEmpty = cronParts.every((part) => part.length === 0)
101+
if (allEmpty) {
102+
if (
103+
allowEmpty === 'never' ||
104+
(!firstRender && allowEmpty === 'for-default-value')
105+
) {
106+
error = true
107+
}
108+
}
109+
110+
// Per-dropdown allowEmpty check
111+
if (!error) {
112+
error = hasDropdownAllowEmptyError(
113+
cronParts,
114+
period,
115+
firstRender,
116+
dropdownsConfig,
117+
)
118+
}
97119
} catch {
98120
// Specific errors are not handle (yet)
99121
error = true
@@ -106,6 +128,56 @@ export function setValuesFromCronString(
106128
}
107129
}
108130

131+
/**
132+
* Check if any active dropdown has an empty value that should trigger an error
133+
* based on its per-dropdown allowEmpty configuration
134+
*/
135+
export function hasDropdownAllowEmptyError(
136+
cronParts: number[][],
137+
period: PeriodType,
138+
allowForDefaultValue: boolean,
139+
dropdownsConfig?: DropdownsConfig,
140+
): boolean {
141+
if (!dropdownsConfig || period === 'reboot') return false
142+
143+
const fields: {
144+
key: 'minutes' | 'hours' | 'month-days' | 'months' | 'week-days'
145+
index: number
146+
isActive: boolean
147+
}[] = [
148+
{ key: 'minutes', index: 0, isActive: period !== 'minute' },
149+
{
150+
key: 'hours',
151+
index: 1,
152+
isActive: period !== 'minute' && period !== 'hour',
153+
},
154+
{
155+
key: 'month-days',
156+
index: 2,
157+
isActive: period === 'year' || period === 'month',
158+
},
159+
{ key: 'months', index: 3, isActive: period === 'year' },
160+
{
161+
key: 'week-days',
162+
index: 4,
163+
isActive: period === 'year' || period === 'month' || period === 'week',
164+
},
165+
]
166+
167+
return fields.some(({ key, index, isActive }) => {
168+
if (!isActive) return false
169+
170+
const fieldAllowEmpty = dropdownsConfig[key]?.allowEmpty
171+
if (!fieldAllowEmpty) return false
172+
if (cronParts[index].length !== 0) return false
173+
174+
return (
175+
fieldAllowEmpty === 'never' ||
176+
(fieldAllowEmpty === 'for-default-value' && !allowForDefaultValue)
177+
)
178+
})
179+
}
180+
109181
/**
110182
* Get cron string from values
111183
*/

src/tests/Cron.defaultValue.test.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,73 @@ describe('Cron defaultValue test suite', () => {
801801
hoursSelect: undefined,
802802
minutesSelect: '10',
803803
},
804+
{
805+
title:
806+
'that * * * * * is not allowed for default value with allowEmpty never',
807+
defaultValue: '* * * * *',
808+
allowEmpty: 'never',
809+
periodSelect: 'minute',
810+
monthsSelect: undefined,
811+
monthDaysSelect: undefined,
812+
weekDaysSelect: undefined,
813+
hoursSelect: undefined,
814+
minutesSelect: undefined,
815+
error: defaultError,
816+
},
817+
{
818+
title: 'that * * * * * is allowed with allowEmpty always',
819+
defaultValue: '* * * * *',
820+
allowEmpty: 'always',
821+
periodSelect: 'minute',
822+
monthsSelect: undefined,
823+
monthDaysSelect: undefined,
824+
weekDaysSelect: undefined,
825+
hoursSelect: undefined,
826+
minutesSelect: undefined,
827+
},
828+
{
829+
title:
830+
'that per-dropdown allowEmpty never triggers error when field is empty',
831+
defaultValue: '* * * * 1',
832+
dropdownsConfig: {
833+
hours: { allowEmpty: 'never' },
834+
},
835+
periodSelect: 'week',
836+
monthsSelect: undefined,
837+
monthDaysSelect: undefined,
838+
weekDaysSelect: 'MON',
839+
hoursSelect: 'every hour',
840+
minutesSelect: 'every minute',
841+
error: defaultError,
842+
},
843+
{
844+
title:
845+
'that per-dropdown allowEmpty for-default-value allows empty for default',
846+
defaultValue: '* * * * 1',
847+
dropdownsConfig: {
848+
hours: { allowEmpty: 'for-default-value' },
849+
},
850+
periodSelect: 'week',
851+
monthsSelect: undefined,
852+
monthDaysSelect: undefined,
853+
weekDaysSelect: 'MON',
854+
hoursSelect: 'every hour',
855+
minutesSelect: 'every minute',
856+
},
857+
{
858+
title:
859+
'that per-dropdown allowEmpty never does not trigger error when field has value',
860+
defaultValue: '0 12 * * 1',
861+
dropdownsConfig: {
862+
hours: { allowEmpty: 'never' },
863+
},
864+
periodSelect: 'week',
865+
monthsSelect: undefined,
866+
monthDaysSelect: undefined,
867+
weekDaysSelect: 'MON',
868+
hoursSelect: '12',
869+
minutesSelect: '0',
870+
},
804871
]
805872

806873
test.each(cases)(

0 commit comments

Comments
 (0)