@defra/lis-infra-ui-services 0.49.0 → 0.50.0-lreg-875.alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -2
- package/package.json +5 -1
- package/src/components/date-picker-calendar-day.js +135 -0
- package/src/components/date-picker-dates.js +125 -0
- package/src/components/date-picker-dates.test.js +197 -0
- package/src/components/date-picker-edge-cases.test.js +293 -0
- package/src/components/date-picker-options.test.js +464 -0
- package/src/components/date-picker-templates.js +123 -0
- package/src/components/date-picker.js +780 -0
- package/src/components/date-picker.scss +319 -0
- package/src/components/date-picker.test.js +734 -0
- package/src/nunjucks/components/date-picker/date-picker.test.js +185 -0
- package/src/nunjucks/components/date-picker/macro.njk +3 -0
- package/src/nunjucks/components/date-picker/template.njk +61 -0
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ const nunjucksConfig = createNunjucksConfig({
|
|
|
53
53
|
await server.register(nunjucksConfig)
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
The template components are `appHeading`, `appAutocomplete`, `lisSubNavigation`, plus the search form,
|
|
56
|
+
The template components are `appHeading`, `appAutocomplete`, `lisDatePicker`, `lisSubNavigation`, plus the search form,
|
|
57
57
|
sortable paginated table and date templates. `createNunjucksContextBuilder` (also exported from
|
|
58
58
|
`/nunjucks-context`) builds just the view context. The filters `formatAge`, `formatDate`,
|
|
59
59
|
`formatEarTag`, `formatCurrency` and `queryString` are also exported from `/nunjucks/filters`, and the
|
|
@@ -294,10 +294,37 @@ import { initAllAutocompletes } from '@defra/lis-infra-ui-services/components/au
|
|
|
294
294
|
initAllAutocompletes()
|
|
295
295
|
```
|
|
296
296
|
|
|
297
|
+
#### Date picker
|
|
298
|
+
|
|
299
|
+
The `lisDatePicker` template component adds a calendar dialog to a GOV.UK text input, adapted from the [MoJ Frontend date picker](https://design-patterns.service.justice.gov.uk/components/date-picker) (MIT licence, see [LICENCE-moj-frontend.txt](./LICENCE-moj-frontend.txt)). It takes the same `id`, `name`, `value`, `label`, `hint`, `errorMessage`, `formGroup`, `classes` and `attributes` as `govukInput`, plus `minDate` and `maxDate` (`d/m/yyyy`), `excludedDates`, `excludedDays` (day names separated by spaces), `leadingZeros` (picked dates are `27/3/2026` by default, `27/03/2026` when `true`) and `weekStartDay` (`monday` or `sunday`, default `monday`).
|
|
300
|
+
|
|
301
|
+
`excludedDates` takes dates and ranges separated by spaces, for example `"1/6/2026 10/6/2026-12/6/2026"`. A hyphen always marks a range, so dates there must use `/` (or `.` or `,`) between their parts, never `-`. Anything that is not a real date, such as `31/4/2026` or `1-6-2026`, is ignored.
|
|
302
|
+
|
|
303
|
+
```njk
|
|
304
|
+
{% from "date-picker/macro.njk" import lisDatePicker %}
|
|
305
|
+
|
|
306
|
+
{{ lisDatePicker({
|
|
307
|
+
id: "movement-date",
|
|
308
|
+
name: "movementDate",
|
|
309
|
+
label: { text: "Date of movement" },
|
|
310
|
+
hint: { text: "For example, 27/3/2026" },
|
|
311
|
+
maxDate: "5/11/2026"
|
|
312
|
+
}) }}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Start `DatePicker` from `@defra/lis-infra-ui-services/components/date-picker` with `createAll`, alongside the other components. Without JavaScript the plain text input remains.
|
|
316
|
+
|
|
317
|
+
```js
|
|
318
|
+
import { createAll } from 'govuk-frontend'
|
|
319
|
+
import { DatePicker } from '@defra/lis-infra-ui-services/components/date-picker'
|
|
320
|
+
|
|
321
|
+
createAll(DatePicker)
|
|
322
|
+
```
|
|
323
|
+
|
|
297
324
|
### Styles
|
|
298
325
|
|
|
299
326
|
Import the Sass files you need from your app's stylesheet: `components/autocomplete.scss`,
|
|
300
|
-
`components/search-form.scss`, `components/service-navigation.scss`,
|
|
327
|
+
`components/date-picker.scss`, `components/search-form.scss`, `components/service-navigation.scss`,
|
|
301
328
|
`components/sortable-paginated-table.scss` and `components/sub-navigation.scss`.
|
|
302
329
|
|
|
303
330
|
```scss
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defra/lis-infra-ui-services",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.0-lreg-875.alpha.3",
|
|
4
4
|
"description": "UI-Services shared definitions for the livestock taxonomy topology.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"repository": {
|
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
"./cookies/cookies-policy": "./src/cookies/cookies-policy.js",
|
|
16
16
|
"./components/autocomplete": "./src/components/autocomplete.js",
|
|
17
17
|
"./components/cookie-banner": "./src/components/cookie-banner.js",
|
|
18
|
+
"./components/date-picker": "./src/components/date-picker.js",
|
|
18
19
|
"./components/error-pages": "./src/components/error-pages.js",
|
|
19
20
|
"./components/layout": "./src/components/layout.js",
|
|
20
21
|
"./components/scrollable-table": "./src/components/scrollable-table.js",
|
|
21
22
|
"./components/autocomplete.scss": "./src/components/autocomplete.scss",
|
|
23
|
+
"./components/date-picker.scss": "./src/components/date-picker.scss",
|
|
22
24
|
"./components/search-form.scss": "./src/components/search-form.scss",
|
|
23
25
|
"./components/service-navigation.scss": "./src/components/service-navigation.scss",
|
|
24
26
|
"./components/sortable-paginated-table.scss": "./src/components/sortable-paginated-table.scss",
|
|
@@ -86,6 +88,8 @@
|
|
|
86
88
|
"@defra/lis-hubs-infra-registry": "^0.13.0",
|
|
87
89
|
"@defra/lis-infra-eslint-config": "^0.6.0",
|
|
88
90
|
"@hapi/hapi": "^21.4.10",
|
|
91
|
+
"@testing-library/dom": "^10.4.2",
|
|
92
|
+
"@testing-library/user-event": "^14.6.8",
|
|
89
93
|
"@vitest/coverage-istanbul": "^4.1.10",
|
|
90
94
|
"audit-ci": "^7.1.0",
|
|
91
95
|
"convict": "^6.2.5",
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Adapted from the date picker in MoJ Frontend 12.0.0
|
|
2
|
+
// (https://github.com/ministryofjustice/moj-frontend), MIT licence.
|
|
3
|
+
// Copyright (c) 2019 Ministry of Justice
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Picks the picker's navigation for a calendar key press.
|
|
7
|
+
* @param {object} picker - the date picker that owns the day
|
|
8
|
+
* @param {object} event - keydown event
|
|
9
|
+
* @returns {Function | undefined} the navigation, or undefined for any other key
|
|
10
|
+
*/
|
|
11
|
+
function navigationForKey(picker, event) {
|
|
12
|
+
const navigations = new Map([
|
|
13
|
+
['ArrowLeft', () => picker.focusPreviousDay()],
|
|
14
|
+
['ArrowRight', () => picker.focusNextDay()],
|
|
15
|
+
['ArrowUp', () => picker.focusPreviousWeek()],
|
|
16
|
+
['ArrowDown', () => picker.focusNextWeek()],
|
|
17
|
+
['Home', () => picker.focusFirstDayOfWeek()],
|
|
18
|
+
['End', () => picker.focusLastDayOfWeek()],
|
|
19
|
+
[
|
|
20
|
+
'PageUp',
|
|
21
|
+
() =>
|
|
22
|
+
event.shiftKey
|
|
23
|
+
? picker.focusPreviousYear(event)
|
|
24
|
+
: picker.focusPreviousMonth(event)
|
|
25
|
+
],
|
|
26
|
+
[
|
|
27
|
+
'PageDown',
|
|
28
|
+
() =>
|
|
29
|
+
event.shiftKey
|
|
30
|
+
? picker.focusNextYear(event)
|
|
31
|
+
: picker.focusNextMonth(event)
|
|
32
|
+
]
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
return navigations.get(event.key)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One day button in the calendar grid.
|
|
40
|
+
*/
|
|
41
|
+
export class CalendarDay {
|
|
42
|
+
/**
|
|
43
|
+
* @param {object} $button - the day's button element
|
|
44
|
+
* @param {number} index - position in the grid
|
|
45
|
+
* @param {number} row - grid row
|
|
46
|
+
* @param {number} column - grid column
|
|
47
|
+
* @param {object} picker - the date picker that owns the day
|
|
48
|
+
*/
|
|
49
|
+
constructor($button, index, row, column, picker) {
|
|
50
|
+
this.index = index
|
|
51
|
+
this.row = row
|
|
52
|
+
this.column = column
|
|
53
|
+
this.$button = $button
|
|
54
|
+
this.picker = picker
|
|
55
|
+
this.date = new Date()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {Date} day - the Date for the calendar day
|
|
60
|
+
* @param {boolean} hidden - visibility of the day
|
|
61
|
+
* @param {boolean} disabled - is the day selectable or excluded
|
|
62
|
+
*/
|
|
63
|
+
update(day, hidden, disabled) {
|
|
64
|
+
const label = day.getDate()
|
|
65
|
+
let accessibleLabel = this.picker.formattedDateHuman(day)
|
|
66
|
+
|
|
67
|
+
if (disabled) {
|
|
68
|
+
this.$button.setAttribute('aria-disabled', 'true')
|
|
69
|
+
accessibleLabel = `Excluded date, ${accessibleLabel}`
|
|
70
|
+
} else {
|
|
71
|
+
this.$button.removeAttribute('aria-disabled')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this.$button.hidden = hidden
|
|
75
|
+
this.$button.dataset.testid = this.picker.formattedDateFromDate(day)
|
|
76
|
+
|
|
77
|
+
this.$button.innerHTML = `<span class="govuk-visually-hidden">${accessibleLabel}</span><span aria-hidden="true">${label}</span>`
|
|
78
|
+
this.date = new Date(day)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Called by the picker's delegated click listener on the grid.
|
|
83
|
+
* @param {object} event - click event
|
|
84
|
+
*/
|
|
85
|
+
click(event) {
|
|
86
|
+
this.picker.goToDate(this.date)
|
|
87
|
+
this.picker.selectDate(this.date)
|
|
88
|
+
|
|
89
|
+
event.stopPropagation()
|
|
90
|
+
event.preventDefault()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Called by the picker's delegated keydown listener on the grid.
|
|
95
|
+
* @param {object} event - keydown event
|
|
96
|
+
*/
|
|
97
|
+
keyPress(event) {
|
|
98
|
+
const navigation = navigationForKey(this.picker, event)
|
|
99
|
+
|
|
100
|
+
if (navigation) {
|
|
101
|
+
navigation()
|
|
102
|
+
event.preventDefault()
|
|
103
|
+
event.stopPropagation()
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const DAYS_IN_WEEK = 7
|
|
109
|
+
const CALENDAR_WEEKS = 6
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Fills the calendar's body with six weeks of day buttons.
|
|
113
|
+
* @param {object} $tbody - the calendar table's body
|
|
114
|
+
* @param {object} picker - the date picker that owns the days
|
|
115
|
+
* @returns {CalendarDay[]} the days, in grid order
|
|
116
|
+
*/
|
|
117
|
+
export function createCalendarDays($tbody, picker) {
|
|
118
|
+
const calendarDays = []
|
|
119
|
+
for (let row = 0; row < CALENDAR_WEEKS; row++) {
|
|
120
|
+
const $row = $tbody.insertRow(row)
|
|
121
|
+
|
|
122
|
+
for (let column = 0; column < DAYS_IN_WEEK; column++) {
|
|
123
|
+
const $cell = document.createElement('td')
|
|
124
|
+
$row.appendChild($cell)
|
|
125
|
+
const $dateButton = document.createElement('button')
|
|
126
|
+
$dateButton.setAttribute('type', 'button')
|
|
127
|
+
$cell.appendChild($dateButton)
|
|
128
|
+
|
|
129
|
+
calendarDays.push(
|
|
130
|
+
new CalendarDay($dateButton, calendarDays.length, row, column, picker)
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return calendarDays
|
|
135
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Adapted from the date picker in MoJ Frontend 12.0.0
|
|
2
|
+
// (https://github.com/ministryofjustice/moj-frontend), MIT licence.
|
|
3
|
+
// Copyright (c) 2019 Ministry of Justice
|
|
4
|
+
|
|
5
|
+
// Accepts d/m/yyyy and dd/mm/yyyy, with -, /, comma, full stop or space between
|
|
6
|
+
// parts, and nothing else around them but whitespace.
|
|
7
|
+
const DATE_FORMAT_PATTERN = /^\s*(\d{1,2})([-/,. ])(\d{1,2})\2(\d{4})\s*$/
|
|
8
|
+
const DAY_GROUP = 1
|
|
9
|
+
const MONTH_GROUP = 3
|
|
10
|
+
const YEAR_GROUP = 4
|
|
11
|
+
const PADDED_LENGTH = 2
|
|
12
|
+
const SUNDAY_FIRST_DAYS = [
|
|
13
|
+
'sunday',
|
|
14
|
+
'monday',
|
|
15
|
+
'tuesday',
|
|
16
|
+
'wednesday',
|
|
17
|
+
'thursday',
|
|
18
|
+
'friday',
|
|
19
|
+
'saturday'
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Pads given number with leading zeros
|
|
24
|
+
* @param {number} value - The value to be padded
|
|
25
|
+
* @param {number} length - The length in characters of the output
|
|
26
|
+
* @returns {string} the padded value
|
|
27
|
+
*/
|
|
28
|
+
function padNumber(value, length = PADDED_LENGTH) {
|
|
29
|
+
return value.toString().padStart(length, '0')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get a Date object from a string
|
|
34
|
+
* @param {string} dateString - string in the format d/m/yyyy dd/mm/yyyy
|
|
35
|
+
* @param {Date | null} fallback - value to return if parsing fails
|
|
36
|
+
* @returns {Date | null} the parsed date, or the fallback
|
|
37
|
+
*/
|
|
38
|
+
export function parseDate(dateString, fallback) {
|
|
39
|
+
const match = DATE_FORMAT_PATTERN.exec(dateString)
|
|
40
|
+
|
|
41
|
+
if (!match) {
|
|
42
|
+
return fallback
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const year = Number(match[YEAR_GROUP])
|
|
46
|
+
const monthIndex = Number(match[MONTH_GROUP]) - 1
|
|
47
|
+
const day = Number(match[DAY_GROUP])
|
|
48
|
+
const date = new Date(year, monthIndex, day)
|
|
49
|
+
|
|
50
|
+
// Date rolls impossible dates over (31/4 becomes 1/5), so those are rejected.
|
|
51
|
+
const isRealDate =
|
|
52
|
+
date.getFullYear() === year &&
|
|
53
|
+
date.getMonth() === monthIndex &&
|
|
54
|
+
date.getDate() === day
|
|
55
|
+
return isRealDate ? date : fallback
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parses a daterange string into an array of dates
|
|
60
|
+
* @param {string} rangeString - A daterange string in the format "dd/mm/yyyy-dd/mm/yyyy"
|
|
61
|
+
* @returns {Date[]} every date in the range, or none when either end is invalid
|
|
62
|
+
*/
|
|
63
|
+
export function parseDateRange(rangeString) {
|
|
64
|
+
const dates = []
|
|
65
|
+
const [startDate, endDate] = rangeString
|
|
66
|
+
.split('-')
|
|
67
|
+
.map((part) => parseDate(part, null))
|
|
68
|
+
|
|
69
|
+
if (startDate && endDate) {
|
|
70
|
+
let date = new Date(startDate)
|
|
71
|
+
while (date <= endDate) {
|
|
72
|
+
dates.push(date)
|
|
73
|
+
date = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return dates
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parses whitespace-separated dates and date ranges. A hyphen always marks a
|
|
81
|
+
* range, so the dates must not use it between their parts.
|
|
82
|
+
* @param {string} excludedDates - e.g. "1/6/2026 10/6/2026-12/6/2026"
|
|
83
|
+
* @returns {Date[]} the valid dates; anything that is not a date is dropped
|
|
84
|
+
*/
|
|
85
|
+
export function parseExcludedDates(excludedDates) {
|
|
86
|
+
return excludedDates
|
|
87
|
+
.trim()
|
|
88
|
+
.split(/\s+/)
|
|
89
|
+
.map((item) =>
|
|
90
|
+
item.includes('-') ? parseDateRange(item) : [parseDate(item, null)]
|
|
91
|
+
)
|
|
92
|
+
.reduce((dates, items) => dates.concat(items), [])
|
|
93
|
+
.filter(Boolean)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Parses whitespace-separated day names into Date.getDay() indexes.
|
|
98
|
+
* @param {string} excludedDays - e.g. "saturday sunday"
|
|
99
|
+
* @returns {number[]} the indexes of the recognised days
|
|
100
|
+
*/
|
|
101
|
+
export function parseExcludedDays(excludedDays) {
|
|
102
|
+
// Sunday first, so a day's index matches Date.getDay(). MoJ's version reads
|
|
103
|
+
// the week start day before it is normalised, which shifts every excluded
|
|
104
|
+
// day back by one when the week starts on Sunday.
|
|
105
|
+
return excludedDays
|
|
106
|
+
.trim()
|
|
107
|
+
.toLowerCase()
|
|
108
|
+
.split(/\s+/)
|
|
109
|
+
.map((item) => SUNDAY_FIRST_DAYS.indexOf(item))
|
|
110
|
+
.filter((item) => item !== -1)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get a formatted date string from a Date object
|
|
115
|
+
* @param {Date} date - date to format to a string
|
|
116
|
+
* @param {boolean} leadingZeros - pad the day and month to two digits
|
|
117
|
+
* @returns {string} the date as d/m/yyyy, or dd/mm/yyyy with leading zeros
|
|
118
|
+
*/
|
|
119
|
+
export function formatDate(date, leadingZeros) {
|
|
120
|
+
if (leadingZeros) {
|
|
121
|
+
return `${padNumber(date.getDate())}/${padNumber(date.getMonth() + 1)}/${date.getFullYear()}`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`
|
|
125
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
formatDate,
|
|
5
|
+
parseDate,
|
|
6
|
+
parseDateRange,
|
|
7
|
+
parseExcludedDates,
|
|
8
|
+
parseExcludedDays
|
|
9
|
+
} from './date-picker-dates.js'
|
|
10
|
+
|
|
11
|
+
const FALLBACK = new Date(2000, 0, 1)
|
|
12
|
+
|
|
13
|
+
describe('date-picker-dates', () => {
|
|
14
|
+
describe('parseDate()', () => {
|
|
15
|
+
test.each([
|
|
16
|
+
['27/3/2026', new Date(2026, 2, 27)],
|
|
17
|
+
['07/03/2026', new Date(2026, 2, 7)],
|
|
18
|
+
['27-3-2026', new Date(2026, 2, 27)],
|
|
19
|
+
['27.3.2026', new Date(2026, 2, 27)],
|
|
20
|
+
['27,3,2026', new Date(2026, 2, 27)],
|
|
21
|
+
['27 3 2026', new Date(2026, 2, 27)],
|
|
22
|
+
[' 27/3/2026 ', new Date(2026, 2, 27)],
|
|
23
|
+
['29/2/2024', new Date(2024, 1, 29)]
|
|
24
|
+
])('reads %j as a date', (dateString, expected) => {
|
|
25
|
+
// Act
|
|
26
|
+
const result = parseDate(dateString, FALLBACK)
|
|
27
|
+
|
|
28
|
+
// Assert
|
|
29
|
+
expect(result).toStrictEqual(expected)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test.each([
|
|
33
|
+
'31/2/2026',
|
|
34
|
+
'29/2/2026',
|
|
35
|
+
'31/4/2026',
|
|
36
|
+
'45/13/2026',
|
|
37
|
+
'0/1/2026',
|
|
38
|
+
'1/0/2026'
|
|
39
|
+
])('rejects the impossible date %j', (dateString) => {
|
|
40
|
+
// Act
|
|
41
|
+
const result = parseDate(dateString, FALLBACK)
|
|
42
|
+
|
|
43
|
+
// Assert
|
|
44
|
+
expect(result).toBe(FALLBACK)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test.each([
|
|
48
|
+
'127/3/20261',
|
|
49
|
+
'27/3/20261',
|
|
50
|
+
'27/3/26',
|
|
51
|
+
'on 27/3/2026',
|
|
52
|
+
'27/3/2026 or later',
|
|
53
|
+
'27/3-2026',
|
|
54
|
+
'',
|
|
55
|
+
'soon'
|
|
56
|
+
])('rejects %j, which is not just a date', (dateString) => {
|
|
57
|
+
// Act
|
|
58
|
+
const result = parseDate(dateString, FALLBACK)
|
|
59
|
+
|
|
60
|
+
// Assert
|
|
61
|
+
expect(result).toBe(FALLBACK)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('returns a null fallback when asked', () => {
|
|
65
|
+
// Act
|
|
66
|
+
const result = parseDate('31/4/2026', null)
|
|
67
|
+
|
|
68
|
+
// Assert
|
|
69
|
+
expect(result).toBeNull()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('parseDateRange()', () => {
|
|
74
|
+
test('returns every date from the start to the end', () => {
|
|
75
|
+
// Act
|
|
76
|
+
const result = parseDateRange('30/4/2026-2/5/2026')
|
|
77
|
+
|
|
78
|
+
// Assert
|
|
79
|
+
expect(result).toStrictEqual([
|
|
80
|
+
new Date(2026, 3, 30),
|
|
81
|
+
new Date(2026, 4, 1),
|
|
82
|
+
new Date(2026, 4, 2)
|
|
83
|
+
])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('returns no dates when the end is before the start', () => {
|
|
87
|
+
// Act
|
|
88
|
+
const result = parseDateRange('2/5/2026-30/4/2026')
|
|
89
|
+
|
|
90
|
+
// Assert
|
|
91
|
+
expect(result).toStrictEqual([])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test.each(['1/5/2026-31/4/2026', '1/5/2026-soon', '1-5-2026'])(
|
|
95
|
+
'returns no dates for %j',
|
|
96
|
+
(rangeString) => {
|
|
97
|
+
// Act
|
|
98
|
+
const result = parseDateRange(rangeString)
|
|
99
|
+
|
|
100
|
+
// Assert
|
|
101
|
+
expect(result).toStrictEqual([])
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('parseExcludedDates()', () => {
|
|
107
|
+
test.each([
|
|
108
|
+
['a trailing space', '1/6/2026 ', [new Date(2026, 5, 1)]],
|
|
109
|
+
[
|
|
110
|
+
'leading and repeated spaces',
|
|
111
|
+
' 1/6/2026 3/6/2026',
|
|
112
|
+
[new Date(2026, 5, 1), new Date(2026, 5, 3)]
|
|
113
|
+
],
|
|
114
|
+
[
|
|
115
|
+
'newlines and tabs',
|
|
116
|
+
'1/6/2026\n\t3/6/2026\n',
|
|
117
|
+
[new Date(2026, 5, 1), new Date(2026, 5, 3)]
|
|
118
|
+
],
|
|
119
|
+
[
|
|
120
|
+
'an invalid token',
|
|
121
|
+
'1/6/2026 soon 3/6/2026',
|
|
122
|
+
[new Date(2026, 5, 1), new Date(2026, 5, 3)]
|
|
123
|
+
],
|
|
124
|
+
[
|
|
125
|
+
'an impossible date',
|
|
126
|
+
'1/6/2026 31/6/2026 3/6/2026',
|
|
127
|
+
[new Date(2026, 5, 1), new Date(2026, 5, 3)]
|
|
128
|
+
],
|
|
129
|
+
[
|
|
130
|
+
'a date with hyphens between its parts',
|
|
131
|
+
'1/6/2026 2-6-2026 3/6/2026',
|
|
132
|
+
[new Date(2026, 5, 1), new Date(2026, 5, 3)]
|
|
133
|
+
]
|
|
134
|
+
])('keeps only the real dates with %s', (_, excludedDates, expected) => {
|
|
135
|
+
// Act
|
|
136
|
+
const result = parseExcludedDates(excludedDates)
|
|
137
|
+
|
|
138
|
+
// Assert
|
|
139
|
+
expect(result).toStrictEqual(expected)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('combines single dates and ranges', () => {
|
|
143
|
+
// Act
|
|
144
|
+
const result = parseExcludedDates('1/6/2026 10/6/2026-12/6/2026')
|
|
145
|
+
|
|
146
|
+
// Assert
|
|
147
|
+
expect(result).toStrictEqual([
|
|
148
|
+
new Date(2026, 5, 1),
|
|
149
|
+
new Date(2026, 5, 10),
|
|
150
|
+
new Date(2026, 5, 11),
|
|
151
|
+
new Date(2026, 5, 12)
|
|
152
|
+
])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test.each(['', ' ', 'soon'])(
|
|
156
|
+
'returns no dates for %j',
|
|
157
|
+
(excludedDates) => {
|
|
158
|
+
// Act
|
|
159
|
+
const result = parseExcludedDates(excludedDates)
|
|
160
|
+
|
|
161
|
+
// Assert
|
|
162
|
+
expect(result).toStrictEqual([])
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('parseExcludedDays()', () => {
|
|
168
|
+
test('reads day names in any case and spacing as Date.getDay() indexes', () => {
|
|
169
|
+
// Act
|
|
170
|
+
const result = parseExcludedDays(' Saturday \n sunday MONDAY ')
|
|
171
|
+
|
|
172
|
+
// Assert
|
|
173
|
+
expect(result).toStrictEqual([6, 0, 1])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('drops words that are not day names', () => {
|
|
177
|
+
// Act
|
|
178
|
+
const result = parseExcludedDays('sunday someday sat')
|
|
179
|
+
|
|
180
|
+
// Assert
|
|
181
|
+
expect(result).toStrictEqual([0])
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('formatDate()', () => {
|
|
186
|
+
test.each([
|
|
187
|
+
[false, '7/3/2026'],
|
|
188
|
+
[true, '07/03/2026']
|
|
189
|
+
])('with leading zeros %s formats as %s', (leadingZeros, expected) => {
|
|
190
|
+
// Act
|
|
191
|
+
const result = formatDate(new Date(2026, 2, 7), leadingZeros)
|
|
192
|
+
|
|
193
|
+
// Assert
|
|
194
|
+
expect(result).toBe(expected)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
})
|