Skip to content

Commit fbfdd5e

Browse files
feat(Field.Date): add built in error messaging for min and max dates (#4469)
Co-authored-by: Tobias Høegh <tobias@tujo.no>
1 parent 9d2bd61 commit fbfdd5e

File tree

8 files changed

+814
-24
lines changed

8 files changed

+814
-24
lines changed

packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/Date/Examples.tsx

+16
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Provider from '@dnb/eufemia/src/shared/Provider'
12
import ComponentBox from '../../../../../../shared/tags/ComponentBox'
23
import { Field } from '@dnb/eufemia/src/extensions/forms'
34

@@ -98,3 +99,18 @@ export const AutoClose = () => {
9899
</ComponentBox>
99100
)
100101
}
102+
103+
export const DatePickerDateLimitValidation = () => {
104+
return (
105+
<Provider locale="en-GB">
106+
<ComponentBox>
107+
<Field.Date
108+
value="2024-12-31|2025-02-01"
109+
minDate="2025-01-01"
110+
maxDate="2025-01-31"
111+
range
112+
/>
113+
</ComponentBox>
114+
</Provider>
115+
)
116+
}

packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/Date/demos.mdx

+6
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ To enable the picker to close automatically, you have to set `showCancelButton`
3838

3939
<Examples.WithError />
4040

41+
### Date limit validation
42+
43+
The Date field will automatically display an error message if the selected date is before `minDate` or after `maxDate`.
44+
45+
<Examples.DatePickerDateLimitValidation />
46+
4147
### Validation - Required
4248

4349
<Examples.ValidationRequired />

packages/dnb-eufemia/src/extensions/forms/Field/Date/Date.tsx

+152-17
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,20 @@ import { pickSpacingProps } from '../../../../components/flex/utils'
66
import classnames from 'classnames'
77
import FieldBlock, { Props as FieldBlockProps } from '../../FieldBlock'
88
import SharedContext from '../../../../shared/Context'
9-
import { parseISO, isValid } from 'date-fns'
9+
import { parseISO, isValid, isBefore, isAfter } from 'date-fns'
1010
import useTranslation from '../../hooks/useTranslation'
11-
import { formatDate } from '../../Value/Date'
11+
import { FormatDateOptions, formatDate } from '../../Value/Date'
1212
import {
1313
DatePickerEvent,
1414
DatePickerProps,
1515
} from '../../../../components/DatePicker'
16+
import { convertStringToDate } from '../../../../components/date-picker/DatePickerCalc'
17+
import { ProviderProps } from '../../../../shared/Provider'
18+
import { FormError } from '../../utils'
1619

1720
// `range`, `showInput`, `showCancelButton` and `showResetButton` are not picked from the `DatePickerProps`
1821
// Since they require `Field.Date` specific comments, due to them having different default values
19-
export type Props = FieldProps<string, undefined | string> & {
22+
export type DateProps = FieldProps<string, undefined | string> & {
2023
// Validation
2124
pattern?: string
2225
/**
@@ -77,17 +80,17 @@ export type Props = FieldProps<string, undefined | string> & {
7780
| 'onReset'
7881
>
7982

80-
function DateComponent(props: Props) {
81-
const translations = useTranslation()
83+
function DateComponent(props: DateProps) {
84+
const translations = useTranslation().Date
8285
const { locale } = useContext(SharedContext)
8386

8487
const errorMessages = useMemo(() => {
8588
return {
86-
'Field.errorRequired': translations.Date.errorRequired,
87-
'Field.errorPattern': translations.Date.errorRequired,
89+
'Field.errorRequired': translations.errorRequired,
90+
'Field.errorPattern': translations.errorRequired,
8891
...props.errorMessages,
8992
}
90-
}, [props.errorMessages, translations.Date.errorRequired])
93+
}, [props.errorMessages, translations.errorRequired])
9194

9295
const schema = useMemo<AllJSONSchemaVersions>(
9396
() =>
@@ -109,7 +112,31 @@ function DateComponent(props: Props) {
109112
[]
110113
)
111114

112-
const preparedProps: Props = {
115+
const dateLimitValidator = useCallback(
116+
(value: string) => {
117+
return validateDateLimit({
118+
value,
119+
locale,
120+
minDate: props.minDate,
121+
maxDate: props.maxDate,
122+
isRange: props.range,
123+
})
124+
},
125+
[props.maxDate, props.minDate, props.range, locale]
126+
)
127+
128+
const hasDateLimitAndValue = useMemo(() => {
129+
return (props.minDate || props.maxDate) && props.value
130+
}, [props.minDate, props.maxDate, props.value])
131+
132+
const validateInitially = useMemo(() => {
133+
if (hasDateLimitAndValue && !props.validateInitially) {
134+
return true
135+
}
136+
return props.validateInitially
137+
}, [props.validateInitially, hasDateLimitAndValue])
138+
139+
const preparedProps: DateProps = {
113140
...props,
114141
errorMessages,
115142
schema,
@@ -121,6 +148,8 @@ function DateComponent(props: Props) {
121148
return range ? `${start_date}|${end_date}` : date
122149
},
123150
validateRequired,
151+
validateInitially,
152+
onBlurValidator: props.onBlurValidator ?? dateLimitValidator,
124153
}
125154

126155
const {
@@ -142,6 +171,8 @@ function DateComponent(props: Props) {
142171
showResetButton = true,
143172
showInput = true,
144173
onReset,
174+
minDate,
175+
maxDate,
145176
...rest
146177
} = useFieldProps(preparedProps)
147178

@@ -157,27 +188,24 @@ function DateComponent(props: Props) {
157188
}
158189
}
159190

160-
const [startDate, endDate] = valueProp
161-
.split('|')
162-
// Assign to null if falsy value, to properly clear input values
163-
.map((value) => (/(undefined|null)/.test(value) ? null : value))
191+
const [startDate, endDate] = parseRangeValue(valueProp)
164192

165193
return {
166-
value: undefined,
194+
date: undefined,
167195
startDate,
168196
endDate,
169197
}
170198
}, [range, valueProp])
171199

172200
useMemo(() => {
173201
if ((path || itemPath) && valueProp) {
174-
setDisplayValue(formatDate(valueProp, { locale }))
202+
setDisplayValue(formatDate(valueProp, { locale }), undefined)
175203
}
176204
}, [itemPath, locale, path, setDisplayValue, valueProp])
177205

178206
const fieldBlockProps: FieldBlockProps = {
179207
forId: id,
180-
label: label ?? translations.Date.label,
208+
label: label ?? translations.label,
181209
className: classnames('dnb-forms-field-string', className),
182210
...pickSpacingProps(props),
183211
}
@@ -193,6 +221,8 @@ function DateComponent(props: Props) {
193221
showResetButton={showResetButton}
194222
startDate={startDate}
195223
endDate={endDate}
224+
minDate={minDate}
225+
maxDate={maxDate}
196226
status={hasError ? 'error' : undefined}
197227
range={range}
198228
onChange={handleChange}
@@ -209,6 +239,111 @@ function DateComponent(props: Props) {
209239
)
210240
}
211241

242+
function parseRangeValue(value: DateProps['value']) {
243+
return (
244+
value
245+
.split('|')
246+
// Assign to null if falsy value, to properly clear input values
247+
.map((value) => (/(undefined|null)/.test(value) ? null : value))
248+
)
249+
}
250+
251+
function validateDateLimit({
252+
value,
253+
isRange,
254+
locale,
255+
...dates
256+
}: {
257+
value: DateProps['value']
258+
minDate: DateProps['minDate']
259+
maxDate: DateProps['maxDate']
260+
isRange: DateProps['range']
261+
locale: ProviderProps['locale']
262+
}) {
263+
if ((!dates.minDate && !dates.maxDate) || !value) {
264+
return
265+
}
266+
267+
const [startDateParsed, endDateParsed] = parseRangeValue(value)
268+
269+
const minDate = convertStringToDate(dates.minDate)
270+
const maxDate = convertStringToDate(dates.maxDate)
271+
272+
const startDate = convertStringToDate(startDateParsed)
273+
const endDate = convertStringToDate(endDateParsed)
274+
275+
const isoDates = {
276+
minDate:
277+
dates.minDate instanceof Date
278+
? dates.minDate.toISOString()
279+
: dates.minDate,
280+
maxDate:
281+
dates.maxDate instanceof Date
282+
? dates.maxDate.toISOString()
283+
: dates.maxDate,
284+
}
285+
286+
const options: FormatDateOptions = {
287+
locale,
288+
variant: 'long',
289+
}
290+
291+
// Handle non range validation
292+
if (!isRange) {
293+
if (isBefore(startDate, minDate)) {
294+
return new FormError('Date.errorMinDate', {
295+
messageValues: { date: formatDate(isoDates.minDate, options) },
296+
})
297+
}
298+
299+
if (isAfter(startDate, maxDate)) {
300+
return new FormError('Date.errorMaxDate', {
301+
messageValues: { date: formatDate(isoDates.maxDate, options) },
302+
})
303+
}
304+
305+
return
306+
}
307+
308+
const messages: Array<FormError> = []
309+
310+
// Start date validation
311+
if (isBefore(startDate, minDate)) {
312+
messages.push(
313+
new FormError('Date.errorStartDateMinDate', {
314+
messageValues: { date: formatDate(isoDates.minDate, options) },
315+
})
316+
)
317+
}
318+
319+
if (isAfter(startDate, maxDate)) {
320+
messages.push(
321+
new FormError('Date.errorStartDateMaxDate', {
322+
messageValues: { date: formatDate(isoDates.maxDate, options) },
323+
})
324+
)
325+
}
326+
327+
// End date validation
328+
if (isBefore(endDate, minDate)) {
329+
messages.push(
330+
new FormError('Date.errorEndDateMinDate', {
331+
messageValues: { date: formatDate(isoDates.minDate, options) },
332+
})
333+
)
334+
}
335+
336+
if (isAfter(endDate, maxDate)) {
337+
messages.push(
338+
new FormError('Date.errorEndDateMaxDate', {
339+
messageValues: { date: formatDate(isoDates.maxDate, options) },
340+
})
341+
)
342+
}
343+
344+
return messages
345+
}
346+
212347
// Used to filter out DatePickerProps from the FieldProps.
213348
// Includes DatePickerProps that are not destructured in useFieldProps
214349
const datePickerPropKeys = [
@@ -251,7 +386,7 @@ const datePickerPropKeys = [
251386
'onReset',
252387
]
253388

254-
function pickDatePickerProps(props: Props) {
389+
function pickDatePickerProps(props: DateProps) {
255390
const datePickerProps = Object.keys(props).reduce(
256391
(datePickerProps, key) => {
257392
if (datePickerPropKeys.includes(key)) {

0 commit comments

Comments
 (0)