@defra/lis-infra-ui-services 0.12.0 → 0.13.0
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/package.json +1 -1
- package/src/components/sortable-paginated-table.scss +31 -0
- package/src/nunjucks/components/date/date.test.js +72 -0
- package/src/nunjucks/components/date/macro.njk +3 -0
- package/src/nunjucks/components/date/template.njk +3 -0
- package/src/nunjucks/components/search-form/search-form.test.js +19 -0
- package/src/nunjucks/components/search-form/template.njk +2 -1
- package/src/nunjucks/components/sortable-paginated-table/sortable-paginated-table.test.js +50 -0
- package/src/nunjucks/components/sortable-paginated-table/template.njk +20 -11
- package/src/nunjucks/filters.js +2 -0
- package/src/nunjucks/format-age.js +38 -0
- package/src/nunjucks/format-age.test.js +137 -0
- package/src/nunjucks/format-ear-tag.js +26 -0
- package/src/nunjucks/format-ear-tag.test.js +82 -0
package/package.json
CHANGED
|
@@ -25,3 +25,34 @@
|
|
|
25
25
|
.lis-sortable-table__icon {
|
|
26
26
|
flex-shrink: 0;
|
|
27
27
|
}
|
|
28
|
+
|
|
29
|
+
// A focusable region so keyboard users can scroll a table that's wider than
|
|
30
|
+
// the viewport.
|
|
31
|
+
.lis-sortable-table__wrapper {
|
|
32
|
+
// Contains the table's absolutely positioned visually-hidden text, which
|
|
33
|
+
// would otherwise escape the overflow and make the whole page scroll.
|
|
34
|
+
position: relative;
|
|
35
|
+
overflow-x: auto;
|
|
36
|
+
|
|
37
|
+
// The GOV.UK non-text focus state - yellow alone has too little contrast
|
|
38
|
+
// against a white page. Both rings sit outside the box, clear of the text.
|
|
39
|
+
&:focus {
|
|
40
|
+
outline: $govuk-focus-width solid govuk-functional-colour("focus");
|
|
41
|
+
outline-offset: $govuk-focus-width;
|
|
42
|
+
box-shadow: 0 0 0 $govuk-focus-width govuk-functional-colour("focus-text");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A header never wraps - on a narrow screen the table scrolls inside the
|
|
46
|
+
// wrapper instead.
|
|
47
|
+
.govuk-table__header {
|
|
48
|
+
white-space: nowrap;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Also keeps every cell on one line. Opt-in, since it suits tables of short
|
|
53
|
+
// values but not ones with free text. Pass as `classes`.
|
|
54
|
+
.lis-sortable-table--no-wrap {
|
|
55
|
+
.govuk-table__cell {
|
|
56
|
+
white-space: nowrap;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
|
|
4
|
+
import nunjucks from 'nunjucks'
|
|
5
|
+
import { describe, expect, test } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import { formatDate } from '../../format-date.js'
|
|
8
|
+
|
|
9
|
+
const componentRoot = path.dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
const environment = new nunjucks.Environment(
|
|
11
|
+
new nunjucks.FileSystemLoader([path.dirname(componentRoot)]),
|
|
12
|
+
{ autoescape: true, trimBlocks: true, lstripBlocks: true }
|
|
13
|
+
)
|
|
14
|
+
environment.addFilter('formatDate', formatDate)
|
|
15
|
+
|
|
16
|
+
describe('lisDate()', () => {
|
|
17
|
+
test('it formats an ISO date string as day, short month and year', () => {
|
|
18
|
+
// Arrange
|
|
19
|
+
const params = { value: '2024-11-29' }
|
|
20
|
+
|
|
21
|
+
// Act
|
|
22
|
+
const html = environment.render('date/template.njk', { params })
|
|
23
|
+
|
|
24
|
+
// Assert
|
|
25
|
+
expect(html.trim()).toEqual('29 Nov 2024')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('it does not zero-pad a single-digit day', () => {
|
|
29
|
+
// Arrange
|
|
30
|
+
const params = { value: '2023-02-01' }
|
|
31
|
+
|
|
32
|
+
// Act
|
|
33
|
+
const html = environment.render('date/template.njk', { params })
|
|
34
|
+
|
|
35
|
+
// Assert
|
|
36
|
+
expect(html.trim()).toEqual('1 Feb 2023')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('it formats a Date value', () => {
|
|
40
|
+
// Arrange
|
|
41
|
+
const params = { value: new Date(2024, 10, 29) }
|
|
42
|
+
|
|
43
|
+
// Act
|
|
44
|
+
const html = environment.render('date/template.njk', { params })
|
|
45
|
+
|
|
46
|
+
// Assert
|
|
47
|
+
expect(html.trim()).toEqual('29 Nov 2024')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('it renders nothing when there is no value', () => {
|
|
51
|
+
// Arrange
|
|
52
|
+
const params = { value: null }
|
|
53
|
+
|
|
54
|
+
// Act
|
|
55
|
+
const html = environment.render('date/template.njk', { params })
|
|
56
|
+
|
|
57
|
+
// Assert
|
|
58
|
+
expect(html.trim()).toEqual('')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('it renders with no surrounding whitespace when called inline', () => {
|
|
62
|
+
// Arrange
|
|
63
|
+
const template =
|
|
64
|
+
'{% from "date/macro.njk" import lisDate %}<td>{{ lisDate(value) }}</td>'
|
|
65
|
+
|
|
66
|
+
// Act
|
|
67
|
+
const html = environment.renderString(template, { value: '2024-11-29' })
|
|
68
|
+
|
|
69
|
+
// Assert
|
|
70
|
+
expect(html).toEqual('<td>29 Nov 2024</td>')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -36,6 +36,25 @@ describe('searchForm()', () => {
|
|
|
36
36
|
expect(html).toContain('name="search"')
|
|
37
37
|
})
|
|
38
38
|
|
|
39
|
+
test('links the hint to the input so it is read out with the label', () => {
|
|
40
|
+
// Act
|
|
41
|
+
const html = render({ label: 'Search', hint: 'Search by ear tag' })
|
|
42
|
+
|
|
43
|
+
// Assert
|
|
44
|
+
expect(html).toContain(
|
|
45
|
+
'<p class="govuk-body" id="search-hint">Search by ear tag</p>'
|
|
46
|
+
)
|
|
47
|
+
expect(html).toContain('aria-describedby="search-hint"')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('does not describe the input by a hint when none is given', () => {
|
|
51
|
+
// Act
|
|
52
|
+
const html = render({ label: 'Search' })
|
|
53
|
+
|
|
54
|
+
// Assert
|
|
55
|
+
expect(html).not.toContain('aria-describedby')
|
|
56
|
+
})
|
|
57
|
+
|
|
39
58
|
test('defaults the input name and id to "search"', () => {
|
|
40
59
|
// Act
|
|
41
60
|
const html = render({ label: 'Search' })
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
</h2>
|
|
9
9
|
|
|
10
10
|
{%- if params.hint -%}
|
|
11
|
-
<p class="govuk-body">{{ params.hint }}</p>
|
|
11
|
+
<p class="govuk-body" id="{{ id }}-hint">{{ params.hint }}</p>
|
|
12
12
|
{%- endif -%}
|
|
13
13
|
|
|
14
14
|
<form method="GET" class="lis-search-form">
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
id="{{ id }}"
|
|
20
20
|
name="{{ params.name or 'search' }}"
|
|
21
21
|
value="{{ params.value }}"
|
|
22
|
+
{% if params.hint %}aria-describedby="{{ id }}-hint"{% endif %}
|
|
22
23
|
>
|
|
23
24
|
<div class="lis-search-form__submit-wrapper">
|
|
24
25
|
<button class="lis-search-form__submit" type="submit">
|
|
@@ -123,6 +123,56 @@ test('passes rows straight through to the table', () => {
|
|
|
123
123
|
assert.match(html, /<td class="govuk-table__cell">UK 324537 113234<\/td>/)
|
|
124
124
|
})
|
|
125
125
|
|
|
126
|
+
test('tells screen reader users that the column headers are sortable', () => {
|
|
127
|
+
const html = render({
|
|
128
|
+
caption: 'Animals',
|
|
129
|
+
columns,
|
|
130
|
+
rows,
|
|
131
|
+
baseHref: '/animals'
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
assert.match(
|
|
135
|
+
html,
|
|
136
|
+
/<caption class="govuk-table__caption govuk-visually-hidden">\s*Animals<span class="govuk-visually-hidden"> \(column headers with links are sortable\)\.<\/span>\s*<\/caption>/
|
|
137
|
+
)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('omits the sortable hint when no column has a sortKey', () => {
|
|
141
|
+
const html = render({
|
|
142
|
+
caption: 'Animals',
|
|
143
|
+
columns: [{ text: 'Ear tag number' }, { text: 'Age' }, { text: 'Breed' }],
|
|
144
|
+
rows,
|
|
145
|
+
baseHref: '/animals'
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
assert.doesNotMatch(html, /are sortable/)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('renders no caption, and a generic region label, when no caption is given', () => {
|
|
152
|
+
const html = render({
|
|
153
|
+
columns,
|
|
154
|
+
rows,
|
|
155
|
+
baseHref: '/animals'
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
assert.doesNotMatch(html, /<caption/)
|
|
159
|
+
assert.match(html, /role="region" aria-label="Table">/)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('wraps the table in a focusable, labelled region so it can be scrolled by keyboard', () => {
|
|
163
|
+
const html = render({
|
|
164
|
+
caption: 'Animals',
|
|
165
|
+
columns,
|
|
166
|
+
rows,
|
|
167
|
+
baseHref: '/animals'
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
assert.match(
|
|
171
|
+
html,
|
|
172
|
+
/<div class="lis-sortable-table__wrapper" tabindex="0" role="region" aria-label="Animals table">\s*<table/
|
|
173
|
+
)
|
|
174
|
+
})
|
|
175
|
+
|
|
126
176
|
test('renders the results summary but no pagination nav when there is only one page', () => {
|
|
127
177
|
const html = render({
|
|
128
178
|
caption: 'Animals',
|
|
@@ -4,27 +4,30 @@
|
|
|
4
4
|
{%- set query = params.query or {} -%}
|
|
5
5
|
|
|
6
6
|
{%- set upArrow -%}
|
|
7
|
-
<svg width="22" height="22" focusable="false" aria-hidden="true"
|
|
7
|
+
<svg width="22" height="22" focusable="false" aria-hidden="true" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg" class="lis-sortable-table__icon">
|
|
8
8
|
<path d="M6.5625 15.5L11 6.63125L15.4375 15.5H6.5625Z" fill="currentColor"></path>
|
|
9
9
|
</svg>
|
|
10
10
|
{%- endset -%}
|
|
11
11
|
|
|
12
12
|
{%- set downArrow -%}
|
|
13
|
-
<svg width="22" height="22" focusable="false" aria-hidden="true"
|
|
13
|
+
<svg width="22" height="22" focusable="false" aria-hidden="true" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg" class="lis-sortable-table__icon">
|
|
14
14
|
<path d="M15.4375 7L11 15.8687L6.5625 7L15.4375 7Z" fill="currentColor"></path>
|
|
15
15
|
</svg>
|
|
16
16
|
{%- endset -%}
|
|
17
17
|
|
|
18
18
|
{%- set upDownArrow -%}
|
|
19
|
-
<svg width="22" height="22" focusable="false" aria-hidden="true"
|
|
19
|
+
<svg width="22" height="22" focusable="false" aria-hidden="true" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg" class="lis-sortable-table__icon">
|
|
20
20
|
<path d="M8.1875 9.5L10.9609 3.95703L13.7344 9.5H8.1875Z" fill="currentColor"></path>
|
|
21
21
|
<path d="M13.7344 12.0781L10.9609 17.6211L8.1875 12.0781H13.7344Z" fill="currentColor"></path>
|
|
22
22
|
</svg>
|
|
23
23
|
{%- endset -%}
|
|
24
24
|
|
|
25
25
|
{%- set head = [] -%}
|
|
26
|
+
{#- a list, since a set inside the loop wouldn't be visible outside it -#}
|
|
27
|
+
{%- set sortableColumns = [] -%}
|
|
26
28
|
{%- for column in params.columns -%}
|
|
27
29
|
{%- if column.sortKey -%}
|
|
30
|
+
{%- set _ = sortableColumns.push(column.sortKey) -%}
|
|
28
31
|
{%- set isActive = params.sort == column.sortKey -%}
|
|
29
32
|
{%- set ariaSort = "ascending" if (isActive and params.direction == "asc") else ("descending" if (isActive and params.direction == "desc") else "none") -%}
|
|
30
33
|
{%- set nextDirection = "desc" if ariaSort == "ascending" else "asc" -%}
|
|
@@ -39,14 +42,20 @@
|
|
|
39
42
|
{%- endif -%}
|
|
40
43
|
{%- endfor -%}
|
|
41
44
|
|
|
42
|
-
{
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
{%- set captionHtml -%}
|
|
46
|
+
{{ params.caption }}{% if sortableColumns | length %}<span class="govuk-visually-hidden"> (column headers with links are sortable).</span>{% endif %}
|
|
47
|
+
{%- endset -%}
|
|
48
|
+
|
|
49
|
+
<div class="lis-sortable-table__wrapper" tabindex="0" role="region" aria-label="{{ (params.caption ~ ' table') if params.caption else 'Table' }}">
|
|
50
|
+
{{ govukTable({
|
|
51
|
+
caption: (captionHtml | safe) if params.caption else undefined,
|
|
52
|
+
captionClasses: params.captionClasses or "govuk-visually-hidden",
|
|
53
|
+
classes: params.classes,
|
|
54
|
+
firstCellIsHeader: params.firstCellIsHeader,
|
|
55
|
+
head: head,
|
|
56
|
+
rows: params.rows
|
|
57
|
+
}) }}
|
|
58
|
+
</div>
|
|
50
59
|
|
|
51
60
|
{%- if params.pagination -%}
|
|
52
61
|
{%- set currentPage = params.pagination.currentPage -%}
|
package/src/nunjucks/filters.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import assign from 'lodash/assign.js'
|
|
2
2
|
|
|
3
|
+
export { formatAge } from './format-age.js'
|
|
3
4
|
export { formatDate } from './format-date.js'
|
|
5
|
+
export { formatEarTag } from './format-ear-tag.js'
|
|
4
6
|
export { formatCurrency } from './format-currency.js'
|
|
5
7
|
export { queryString } from './query-string.js'
|
|
6
8
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { intervalToDuration, isDate, isValid, parseISO } from 'date-fns'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {Date | string} dateOfBirth
|
|
5
|
+
* @param {Date} [now]
|
|
6
|
+
* @returns {string} e.g. "3 years, 6 months", "3 years" for a whole number
|
|
7
|
+
* of years, or "6 months" for an animal under a year old. Empty when there
|
|
8
|
+
* is no usable date of birth - missing, unparseable or in the future - so
|
|
9
|
+
* a page never shows a made-up age.
|
|
10
|
+
*/
|
|
11
|
+
export function formatAge(dateOfBirth, now = new Date()) {
|
|
12
|
+
if (!dateOfBirth) {
|
|
13
|
+
return ''
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const start = isDate(dateOfBirth) ? dateOfBirth : parseISO(dateOfBirth)
|
|
17
|
+
|
|
18
|
+
if (!isValid(start) || start > now) {
|
|
19
|
+
return ''
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// intervalToDuration omits a unit entirely (rather than returning 0) when
|
|
23
|
+
// its value is zero, so years/months need a default.
|
|
24
|
+
const { years = 0, months = 0 } = intervalToDuration({ start, end: now })
|
|
25
|
+
|
|
26
|
+
const yearsText = `${years} year${years === 1 ? '' : 's'}`
|
|
27
|
+
const monthsText = `${months} month${months === 1 ? '' : 's'}`
|
|
28
|
+
|
|
29
|
+
if (years === 0) {
|
|
30
|
+
return months === 0 ? 'Less than 1 month' : monthsText
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (months === 0) {
|
|
34
|
+
return yearsText
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return `${yearsText}, ${monthsText}`
|
|
38
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { formatAge } from './format-age.js'
|
|
4
|
+
|
|
5
|
+
describe('formatAge()', () => {
|
|
6
|
+
test('it computes whole years and months', () => {
|
|
7
|
+
// Arrange
|
|
8
|
+
const dateOfBirth = '2023-03-10'
|
|
9
|
+
const now = new Date('2026-09-10')
|
|
10
|
+
|
|
11
|
+
// Act
|
|
12
|
+
const age = formatAge(dateOfBirth, now)
|
|
13
|
+
|
|
14
|
+
// Assert
|
|
15
|
+
expect(age).toBe('3 years, 6 months')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('it uses singular units for exactly 1 year and 1 month', () => {
|
|
19
|
+
// Arrange
|
|
20
|
+
const dateOfBirth = '2025-08-10'
|
|
21
|
+
const now = new Date('2026-09-10')
|
|
22
|
+
|
|
23
|
+
// Act
|
|
24
|
+
const age = formatAge(dateOfBirth, now)
|
|
25
|
+
|
|
26
|
+
// Assert
|
|
27
|
+
expect(age).toBe('1 year, 1 month')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('it omits the months when the age is a whole number of years', () => {
|
|
31
|
+
// Arrange
|
|
32
|
+
const dateOfBirth = '2023-08-19'
|
|
33
|
+
const now = new Date('2026-09-11')
|
|
34
|
+
|
|
35
|
+
// Act
|
|
36
|
+
const age = formatAge(dateOfBirth, now)
|
|
37
|
+
|
|
38
|
+
// Assert
|
|
39
|
+
expect(age).toBe('3 years')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('it uses the singular "year" for a whole number of exactly 1 year', () => {
|
|
43
|
+
// Arrange
|
|
44
|
+
const dateOfBirth = '2025-08-19'
|
|
45
|
+
const now = new Date('2026-09-11')
|
|
46
|
+
|
|
47
|
+
// Act
|
|
48
|
+
const age = formatAge(dateOfBirth, now)
|
|
49
|
+
|
|
50
|
+
// Assert
|
|
51
|
+
expect(age).toBe('1 year')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('it shows only the months for an animal under a year old', () => {
|
|
55
|
+
// Arrange
|
|
56
|
+
const dateOfBirth = '2026-03-10'
|
|
57
|
+
const now = new Date('2026-09-10')
|
|
58
|
+
|
|
59
|
+
// Act
|
|
60
|
+
const age = formatAge(dateOfBirth, now)
|
|
61
|
+
|
|
62
|
+
// Assert
|
|
63
|
+
expect(age).toBe('6 months')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('it uses the singular "month" for an animal exactly 1 month old', () => {
|
|
67
|
+
// Arrange
|
|
68
|
+
const dateOfBirth = '2026-08-10'
|
|
69
|
+
const now = new Date('2026-09-10')
|
|
70
|
+
|
|
71
|
+
// Act
|
|
72
|
+
const age = formatAge(dateOfBirth, now)
|
|
73
|
+
|
|
74
|
+
// Assert
|
|
75
|
+
expect(age).toBe('1 month')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('it shows "Less than 1 month" for an animal under a month old', () => {
|
|
79
|
+
// Arrange
|
|
80
|
+
const dateOfBirth = '2026-09-01'
|
|
81
|
+
const now = new Date('2026-09-10')
|
|
82
|
+
|
|
83
|
+
// Act
|
|
84
|
+
const age = formatAge(dateOfBirth, now)
|
|
85
|
+
|
|
86
|
+
// Assert
|
|
87
|
+
expect(age).toBe('Less than 1 month')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('it accepts a Date value', () => {
|
|
91
|
+
// Arrange
|
|
92
|
+
const dateOfBirth = new Date('2023-03-10')
|
|
93
|
+
const now = new Date('2026-09-10')
|
|
94
|
+
|
|
95
|
+
// Act
|
|
96
|
+
const age = formatAge(dateOfBirth, now)
|
|
97
|
+
|
|
98
|
+
// Assert
|
|
99
|
+
expect(age).toBe('3 years, 6 months')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('it returns an empty string when there is no date of birth', () => {
|
|
103
|
+
// Arrange
|
|
104
|
+
const dateOfBirth = null
|
|
105
|
+
const now = new Date('2026-09-10')
|
|
106
|
+
|
|
107
|
+
// Act
|
|
108
|
+
const age = formatAge(dateOfBirth, now)
|
|
109
|
+
|
|
110
|
+
// Assert
|
|
111
|
+
expect(age).toBe('')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('it returns an empty string for an unparseable date of birth', () => {
|
|
115
|
+
// Arrange
|
|
116
|
+
const dateOfBirth = 'not-a-date'
|
|
117
|
+
const now = new Date('2026-09-10')
|
|
118
|
+
|
|
119
|
+
// Act
|
|
120
|
+
const age = formatAge(dateOfBirth, now)
|
|
121
|
+
|
|
122
|
+
// Assert
|
|
123
|
+
expect(age).toBe('')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('it returns an empty string for a date of birth in the future', () => {
|
|
127
|
+
// Arrange
|
|
128
|
+
const dateOfBirth = '2028-01-01'
|
|
129
|
+
const now = new Date('2026-09-10')
|
|
130
|
+
|
|
131
|
+
// Act
|
|
132
|
+
const age = formatAge(dateOfBirth, now)
|
|
133
|
+
|
|
134
|
+
// Assert
|
|
135
|
+
expect(age).toBe('')
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The UK country code, then a six-digit herd mark and a six-digit animal
|
|
2
|
+
// number - e.g. "UK324537113236".
|
|
3
|
+
const earTagPattern = /^(UK)(\d{6})(\d{6})$/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} earTag
|
|
7
|
+
* @returns {string[] | undefined} the country code, herd mark and animal
|
|
8
|
+
* number, or undefined if the ear tag isn't in that format
|
|
9
|
+
*/
|
|
10
|
+
function earTagGroups(earTag) {
|
|
11
|
+
return earTag.replace(/\s+/g, '').toUpperCase().match(earTagPattern)?.slice(1)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} earTag e.g. "UK324537113236"
|
|
16
|
+
* @returns {string} e.g. "UK 324537 113236" - the country code, herd mark
|
|
17
|
+
* and animal number. Anything else - an ear tag in another format, or a
|
|
18
|
+
* value that isn't a string - is returned as-is.
|
|
19
|
+
*/
|
|
20
|
+
export function formatEarTag(earTag) {
|
|
21
|
+
if (typeof earTag !== 'string') {
|
|
22
|
+
return earTag
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return earTagGroups(earTag)?.join(' ') ?? earTag
|
|
26
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { formatEarTag } from './format-ear-tag.js'
|
|
4
|
+
|
|
5
|
+
describe('formatEarTag()', () => {
|
|
6
|
+
test('it splits an ear tag into country code, herd mark and animal number', () => {
|
|
7
|
+
// Arrange
|
|
8
|
+
const earTag = 'UK324537113236'
|
|
9
|
+
|
|
10
|
+
// Act
|
|
11
|
+
const formatted = formatEarTag(earTag)
|
|
12
|
+
|
|
13
|
+
// Assert
|
|
14
|
+
expect(formatted).toBe('UK 324537 113236')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('it leaves an already formatted ear tag unchanged', () => {
|
|
18
|
+
// Arrange
|
|
19
|
+
const earTag = 'UK 324537 113236'
|
|
20
|
+
|
|
21
|
+
// Act
|
|
22
|
+
const formatted = formatEarTag(earTag)
|
|
23
|
+
|
|
24
|
+
// Assert
|
|
25
|
+
expect(formatted).toBe('UK 324537 113236')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('it upper-cases the country code', () => {
|
|
29
|
+
// Arrange
|
|
30
|
+
const earTag = 'uk324537113236'
|
|
31
|
+
|
|
32
|
+
// Act
|
|
33
|
+
const formatted = formatEarTag(earTag)
|
|
34
|
+
|
|
35
|
+
// Assert
|
|
36
|
+
expect(formatted).toBe('UK 324537 113236')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('it returns an ear tag with another country code as-is', () => {
|
|
40
|
+
// Arrange
|
|
41
|
+
const earTag = 'IE151234567890'
|
|
42
|
+
|
|
43
|
+
// Act
|
|
44
|
+
const formatted = formatEarTag(earTag)
|
|
45
|
+
|
|
46
|
+
// Assert
|
|
47
|
+
expect(formatted).toBe('IE151234567890')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('it returns an ear tag of the wrong length as-is', () => {
|
|
51
|
+
// Arrange
|
|
52
|
+
const earTag = 'UK32453711323'
|
|
53
|
+
|
|
54
|
+
// Act
|
|
55
|
+
const formatted = formatEarTag(earTag)
|
|
56
|
+
|
|
57
|
+
// Assert
|
|
58
|
+
expect(formatted).toBe('UK32453711323')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('it returns an ear tag in another format exactly as given', () => {
|
|
62
|
+
// Arrange
|
|
63
|
+
const earTag = 'ab 1234/x'
|
|
64
|
+
|
|
65
|
+
// Act
|
|
66
|
+
const formatted = formatEarTag(earTag)
|
|
67
|
+
|
|
68
|
+
// Assert
|
|
69
|
+
expect(formatted).toBe('ab 1234/x')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('it returns a missing ear tag as-is', () => {
|
|
73
|
+
// Arrange
|
|
74
|
+
const earTag = null
|
|
75
|
+
|
|
76
|
+
// Act
|
|
77
|
+
const formatted = formatEarTag(earTag)
|
|
78
|
+
|
|
79
|
+
// Assert
|
|
80
|
+
expect(formatted).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
})
|