@ministryofjustice/frontend 3.3.1 → 3.4.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/README.md +4 -10
- package/govuk-prototype-kit.config.json +5 -16
- package/moj/all.jquery.min.js +77 -3
- package/moj/all.js +2021 -1436
- package/moj/all.scss +2 -0
- package/moj/all.spec.js +15 -13
- package/moj/components/_all.scss +1 -0
- package/moj/components/action-bar/_action-bar.scss +4 -6
- package/moj/components/add-another/_add-another.scss +9 -7
- package/moj/components/add-another/add-another.js +90 -69
- package/moj/components/add-another/add-another.spec.js +165 -0
- package/moj/components/alert/README.md +0 -0
- package/moj/components/alert/_alert.scss +142 -0
- package/moj/components/alert/alert.js +247 -0
- package/moj/components/alert/alert.spec.helper.js +67 -0
- package/moj/components/alert/alert.spec.js +229 -0
- package/moj/components/alert/macro.njk +3 -0
- package/moj/components/alert/template.njk +83 -0
- package/moj/components/badge/_badge.scss +3 -4
- package/moj/components/banner/_banner.scss +5 -10
- package/moj/components/button-menu/_button-menu.scss +10 -9
- package/moj/components/button-menu/button-menu.js +139 -136
- package/moj/components/button-menu/button-menu.spec.js +295 -296
- package/moj/components/cookie-banner/_cookie-banner.scss +6 -5
- package/moj/components/currency-input/_currency-input.scss +4 -4
- package/moj/components/date-picker/README.md +14 -17
- package/moj/components/date-picker/_date-picker.scss +122 -106
- package/moj/components/date-picker/date-picker.js +473 -471
- package/moj/components/date-picker/date-picker.spec.js +971 -923
- package/moj/components/filter/README.md +1 -1
- package/moj/components/filter/_filter.scss +53 -75
- package/moj/components/filter-toggle-button/filter-toggle-button.js +71 -67
- package/moj/components/filter-toggle-button/filter-toggle-button.spec.js +203 -205
- package/moj/components/form-validator/form-validator.js +117 -109
- package/moj/components/header/_header.scss +17 -19
- package/moj/components/identity-bar/_identity-bar.scss +5 -5
- package/moj/components/interruption-card/_interruption-card.scss +2 -2
- package/moj/components/messages/_messages.scss +12 -19
- package/moj/components/multi-file-upload/README.md +1 -1
- package/moj/components/multi-file-upload/_multi-file-upload.scss +34 -30
- package/moj/components/multi-file-upload/multi-file-upload.js +188 -152
- package/moj/components/multi-file-upload/multi-file-upload.spec.js +510 -0
- package/moj/components/multi-select/_multi-select.scss +4 -3
- package/moj/components/multi-select/multi-select.js +55 -50
- package/moj/components/multi-select/multi-select.spec.js +72 -79
- package/moj/components/notification-badge/_notification-badge.scss +12 -12
- package/moj/components/organisation-switcher/_organisation-switcher.scss +1 -1
- package/moj/components/page-header-actions/_page-header-actions.scss +3 -2
- package/moj/components/pagination/_pagination.scss +26 -31
- package/moj/components/password-reveal/_password-reveal.scss +1 -2
- package/moj/components/password-reveal/password-reveal.js +22 -21
- package/moj/components/password-reveal/password-reveal.spec.js +39 -37
- package/moj/components/primary-navigation/_primary-navigation.scss +26 -29
- package/moj/components/progress-bar/_progress-bar.scss +21 -26
- package/moj/components/rich-text-editor/_rich-text-editor.scss +17 -16
- package/moj/components/rich-text-editor/rich-text-editor.js +117 -103
- package/moj/components/search/_search.scss +6 -4
- package/moj/components/search-toggle/search-toggle.js +29 -30
- package/moj/components/search-toggle/search-toggle.scss +21 -15
- package/moj/components/search-toggle/search-toggle.spec.js +65 -70
- package/moj/components/side-navigation/_side-navigation.scss +12 -21
- package/moj/components/sortable-table/_sortable-table.scss +25 -23
- package/moj/components/sortable-table/sortable-table.js +139 -117
- package/moj/components/sortable-table/sortable-table.spec.js +362 -0
- package/moj/components/sub-navigation/_sub-navigation.scss +24 -28
- package/moj/components/tag/_tag.scss +8 -9
- package/moj/components/task-list/_task-list.scss +8 -7
- package/moj/components/ticket-panel/_ticket-panel.scss +14 -6
- package/moj/components/timeline/_timeline.scss +18 -20
- package/moj/filters/all.js +28 -30
- package/moj/filters/prototype-kit-13-filters.js +2 -1
- package/moj/helpers/_all.scss +1 -0
- package/moj/helpers/_hidden.scss +1 -1
- package/moj/helpers/_links.scss +20 -0
- package/moj/helpers.js +160 -31
- package/moj/helpers.spec.js +235 -0
- package/moj/init.js +2 -2
- package/moj/moj-frontend.min.css +2 -2
- package/moj/moj-frontend.min.js +77 -3
- package/moj/namespace.js +2 -1
- package/moj/objects/_filter-layout.scss +11 -10
- package/moj/objects/_scrollable-pane.scss +11 -14
- package/moj/settings/_colours.scss +5 -0
- package/moj/settings/_measurements.scss +0 -2
- package/moj/utilities/_hidden.scss +3 -3
- package/moj/utilities/_width-container.scss +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
/* eslint-disable no-new */
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
queryByRole,
|
|
5
|
+
getByLabelText,
|
|
6
|
+
fireEvent
|
|
7
|
+
} = require('@testing-library/dom')
|
|
8
|
+
const { userEvent } = require('@testing-library/user-event')
|
|
9
|
+
const { configureAxe } = require('jest-axe')
|
|
10
|
+
const sinon = require('sinon')
|
|
11
|
+
|
|
12
|
+
require('../../helpers.js')
|
|
13
|
+
require('./multi-file-upload.js')
|
|
14
|
+
|
|
15
|
+
const user = userEvent.setup()
|
|
16
|
+
const axe = configureAxe({
|
|
17
|
+
rules: {
|
|
18
|
+
// disable landmark rules when testing isolated components.
|
|
19
|
+
region: { enabled: false }
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const createComponent = (options = {}) => {
|
|
24
|
+
const html = `
|
|
25
|
+
<div class="govuk-grid-row">
|
|
26
|
+
<div class="govuk-grid-column-two-thirds">
|
|
27
|
+
<div class="moj-multi-file-upload">
|
|
28
|
+
<div class="moj-multi-file__uploaded-files moj-hidden">
|
|
29
|
+
<h2 class="govuk-heading-m">Files added</h2>
|
|
30
|
+
<div class="govuk-summary-list moj-multi-file-upload__list">
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="moj-multi-file-upload__upload">
|
|
34
|
+
<div class="govuk-form-group">
|
|
35
|
+
<label class="govuk-label govuk-label--m" for="documents">
|
|
36
|
+
Upload a file
|
|
37
|
+
</label>
|
|
38
|
+
<input class="govuk-file-upload moj-multi-file-upload__input" id="documents" name="documents" type="file" multiple="">
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>`
|
|
44
|
+
|
|
45
|
+
document.body.insertAdjacentHTML('afterbegin', html)
|
|
46
|
+
const component = document.querySelector('.moj-multi-file-upload')
|
|
47
|
+
return {
|
|
48
|
+
component,
|
|
49
|
+
options: { container: component, ...options }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('Multi-file upload', () => {
|
|
54
|
+
let component
|
|
55
|
+
let options
|
|
56
|
+
let server
|
|
57
|
+
let uploadFileEntryHook
|
|
58
|
+
let uploadFileExitHook
|
|
59
|
+
let uploadFileErrorHook
|
|
60
|
+
let fileDeleteHook
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
server = sinon.fakeServerWithClock.create({
|
|
64
|
+
respondImmediately: true
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
uploadFileEntryHook = sinon.spy()
|
|
68
|
+
uploadFileExitHook = sinon.spy()
|
|
69
|
+
uploadFileErrorHook = sinon.spy()
|
|
70
|
+
fileDeleteHook = sinon.spy()
|
|
71
|
+
;({ component, options } = createComponent({
|
|
72
|
+
uploadFileEntryHook,
|
|
73
|
+
uploadFileExitHook,
|
|
74
|
+
uploadFileErrorHook,
|
|
75
|
+
fileDeleteHook,
|
|
76
|
+
uploadUrl: '/upload',
|
|
77
|
+
deleteUrl: '/delete'
|
|
78
|
+
}))
|
|
79
|
+
|
|
80
|
+
new MOJFrontend.MultiFileUpload(options)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
document.body.innerHTML = ''
|
|
85
|
+
server.restore()
|
|
86
|
+
sinon.restore()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('initialises with enhanced class', () => {
|
|
90
|
+
expect(component).toHaveClass('moj-multi-file-upload--enhanced')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('creates dropzone with correct text', () => {
|
|
94
|
+
const dropzone = component.querySelector('.moj-multi-file-upload__dropzone')
|
|
95
|
+
expect(dropzone).toBeInTheDocument()
|
|
96
|
+
expect(dropzone).toHaveTextContent('Drag and drop files here or')
|
|
97
|
+
expect(dropzone.querySelector('label')).toHaveTextContent('Choose files')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('creates status box for announcements', () => {
|
|
101
|
+
const statusBox = queryByRole(component, 'status')
|
|
102
|
+
expect(statusBox).toBeInTheDocument()
|
|
103
|
+
expect(statusBox).toHaveClass('govuk-visually-hidden')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('File upload handling', () => {
|
|
107
|
+
let file
|
|
108
|
+
let input
|
|
109
|
+
const successResponse = {
|
|
110
|
+
success: {
|
|
111
|
+
messageHtml: 'File uploaded successfully',
|
|
112
|
+
messageText: 'File uploaded successfully'
|
|
113
|
+
},
|
|
114
|
+
file: {
|
|
115
|
+
filename: 'test',
|
|
116
|
+
originalname: 'test.txt'
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
file = new File(['test content'], 'test.txt', { type: 'text/plain' })
|
|
122
|
+
input = component.querySelector('.moj-multi-file-upload__input')
|
|
123
|
+
input = getByLabelText(component, 'Upload a file')
|
|
124
|
+
|
|
125
|
+
// Configure server response for file upload
|
|
126
|
+
server.respondWith('POST', '/upload', [
|
|
127
|
+
200,
|
|
128
|
+
{ 'Content-Type': 'application/json' },
|
|
129
|
+
JSON.stringify(successResponse)
|
|
130
|
+
])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('handles file input change', async () => {
|
|
134
|
+
const changeEvent = new Event('change', { bubbles: true })
|
|
135
|
+
|
|
136
|
+
// input.files is not writable, so we do this to add the files to the input
|
|
137
|
+
Object.defineProperty(input, 'files', {
|
|
138
|
+
value: { files: [file] }
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
fireEvent(input, changeEvent)
|
|
142
|
+
|
|
143
|
+
const feedbackContainer = component.querySelector(
|
|
144
|
+
'.moj-multi-file__uploaded-files'
|
|
145
|
+
)
|
|
146
|
+
expect(feedbackContainer).not.toHaveClass('moj-hidden')
|
|
147
|
+
const newInput = getByLabelText(component, 'Upload a file')
|
|
148
|
+
expect(newInput).toHaveValue('')
|
|
149
|
+
expect(newInput).toHaveFocus()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('displays upload progress', async () => {
|
|
153
|
+
// Create a spy on XMLHttpRequest to simulate upload progress
|
|
154
|
+
const xhr = sinon.useFakeXMLHttpRequest()
|
|
155
|
+
let request
|
|
156
|
+
xhr.onCreate = (req) => {
|
|
157
|
+
request = req
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await user.upload(input, file)
|
|
161
|
+
|
|
162
|
+
request.uploadProgress({
|
|
163
|
+
lengthComputable: true,
|
|
164
|
+
loaded: 50,
|
|
165
|
+
total: 100
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const fileRows = component.querySelectorAll('.moj-multi-file-upload__row')
|
|
169
|
+
const progressElement = component.querySelector(
|
|
170
|
+
'.moj-multi-file-upload__progress'
|
|
171
|
+
)
|
|
172
|
+
const nameElement = component.querySelector(
|
|
173
|
+
'.moj-multi-file-upload__filename'
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
expect(fileRows).toHaveLength(1)
|
|
177
|
+
expect(progressElement).toHaveTextContent('50%')
|
|
178
|
+
expect(nameElement).toHaveTextContent(file.name)
|
|
179
|
+
|
|
180
|
+
xhr.restore()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('handles successful upload', async () => {
|
|
184
|
+
await user.upload(input, file)
|
|
185
|
+
|
|
186
|
+
expect(uploadFileEntryHook).toHaveBeenCalledOnce()
|
|
187
|
+
expect(uploadFileExitHook).toHaveBeenCalledOnce()
|
|
188
|
+
expect(uploadFileExitHook).toHaveBeenCalledAfter(uploadFileEntryHook)
|
|
189
|
+
|
|
190
|
+
const successMessage = component.querySelector(
|
|
191
|
+
'.moj-multi-file-upload__success'
|
|
192
|
+
)
|
|
193
|
+
const deleteButton = component.querySelector(
|
|
194
|
+
'.moj-multi-file-upload__delete'
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
expect(successMessage).toHaveTextContent('File uploaded successfully')
|
|
198
|
+
expect(deleteButton).toBeInTheDocument()
|
|
199
|
+
expect(deleteButton).toHaveAccessibleName(`Delete test.txt`)
|
|
200
|
+
expect(deleteButton).toHaveValue('test')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// eslint-disable-next-line jest/no-disabled-tests -- this fails as the component still attempts to access response.file (line 149)
|
|
204
|
+
test.skip('handles 200 status with error in response json', async () => {
|
|
205
|
+
server.respondWith('POST', '/upload', [
|
|
206
|
+
200,
|
|
207
|
+
{ 'Content-Type': 'application/json' },
|
|
208
|
+
JSON.stringify({
|
|
209
|
+
error: {
|
|
210
|
+
message: 'Upload failed'
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
])
|
|
214
|
+
|
|
215
|
+
await user.upload(input, file)
|
|
216
|
+
|
|
217
|
+
const errorMessage = component.querySelector(
|
|
218
|
+
'.moj-multi-file-upload__error'
|
|
219
|
+
)
|
|
220
|
+
expect(errorMessage).toHaveTextContent('Upload failed')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('handles non 200 response status', async () => {
|
|
224
|
+
server.respondWith('POST', '/upload', [
|
|
225
|
+
500,
|
|
226
|
+
{ 'Content-Type': 'text/plain' },
|
|
227
|
+
''
|
|
228
|
+
])
|
|
229
|
+
|
|
230
|
+
await user.upload(input, file)
|
|
231
|
+
|
|
232
|
+
expect(uploadFileErrorHook).toHaveBeenCalledOnce()
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
describe('File deletion', () => {
|
|
237
|
+
beforeEach(async () => {
|
|
238
|
+
const file = new File(['test content'], 'test.txt', {
|
|
239
|
+
type: 'text/plain'
|
|
240
|
+
})
|
|
241
|
+
const input = component.querySelector('.moj-multi-file-upload__input')
|
|
242
|
+
|
|
243
|
+
server.respondWith('POST', '/upload', [
|
|
244
|
+
200,
|
|
245
|
+
{ 'Content-Type': 'application/json' },
|
|
246
|
+
JSON.stringify({
|
|
247
|
+
success: {
|
|
248
|
+
messageHtml: 'File uploaded successfully'
|
|
249
|
+
},
|
|
250
|
+
file: {
|
|
251
|
+
filename: '123',
|
|
252
|
+
originalname: 'test.txt'
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
])
|
|
256
|
+
|
|
257
|
+
await user.upload(input, file)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
test('handles file deletion', async () => {
|
|
261
|
+
server.respondWith('POST', '/delete', [
|
|
262
|
+
200,
|
|
263
|
+
{ 'Content-Type': 'application/json' },
|
|
264
|
+
JSON.stringify({ success: true })
|
|
265
|
+
])
|
|
266
|
+
|
|
267
|
+
const deleteButton = component.querySelector(
|
|
268
|
+
'.moj-multi-file-upload__delete'
|
|
269
|
+
)
|
|
270
|
+
await user.click(deleteButton)
|
|
271
|
+
|
|
272
|
+
expect(fileDeleteHook).toHaveBeenCalledOnce()
|
|
273
|
+
expect(server.requests[server.requests.length - 1].url).toBe('/delete')
|
|
274
|
+
expect(server.requests[server.requests.length - 1].method).toBe('POST')
|
|
275
|
+
|
|
276
|
+
const fileRow = component.querySelector('.moj-multi-file-upload__row')
|
|
277
|
+
expect(fileRow).not.toBeInTheDocument()
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('hides feedback container when all files are deleted', async () => {
|
|
281
|
+
server.respondWith('POST', '/delete', [
|
|
282
|
+
200,
|
|
283
|
+
{ 'Content-Type': 'application/json' },
|
|
284
|
+
JSON.stringify({ success: true })
|
|
285
|
+
])
|
|
286
|
+
|
|
287
|
+
const deleteButton = component.querySelector(
|
|
288
|
+
'.moj-multi-file-upload__delete'
|
|
289
|
+
)
|
|
290
|
+
await user.click(deleteButton)
|
|
291
|
+
|
|
292
|
+
const feedbackContainer = component.querySelector(
|
|
293
|
+
'.moj-multi-file__uploaded-files'
|
|
294
|
+
)
|
|
295
|
+
expect(feedbackContainer).toHaveClass('moj-hidden')
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
describe('Drag and drop', () => {
|
|
300
|
+
test('handles dragover event', () => {
|
|
301
|
+
const dropzone = component.querySelector(
|
|
302
|
+
'.moj-multi-file-upload__dropzone'
|
|
303
|
+
)
|
|
304
|
+
const dragOverEvent = new Event('dragover')
|
|
305
|
+
dropzone.dispatchEvent(dragOverEvent)
|
|
306
|
+
|
|
307
|
+
expect(dropzone).toHaveClass('moj-multi-file-upload--dragover')
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test('handles dragleave event', () => {
|
|
311
|
+
const dropzone = component.querySelector(
|
|
312
|
+
'.moj-multi-file-upload__dropzone'
|
|
313
|
+
)
|
|
314
|
+
dropzone.classList.add('moj-multi-file-upload--dragover')
|
|
315
|
+
|
|
316
|
+
const dragLeaveEvent = new Event('dragleave')
|
|
317
|
+
dropzone.dispatchEvent(dragLeaveEvent)
|
|
318
|
+
|
|
319
|
+
expect(dropzone).not.toHaveClass('moj-multi-file-upload--dragover')
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
test('handles file drop', () => {
|
|
323
|
+
server.respondWith('POST', '/upload', [
|
|
324
|
+
200,
|
|
325
|
+
{ 'Content-Type': 'application/json' },
|
|
326
|
+
JSON.stringify({
|
|
327
|
+
success: {
|
|
328
|
+
messageHtml: 'File uploaded successfully'
|
|
329
|
+
},
|
|
330
|
+
file: {
|
|
331
|
+
filename: 'test',
|
|
332
|
+
originalname: 'test.txt'
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
])
|
|
336
|
+
|
|
337
|
+
const dropzone = component.querySelector(
|
|
338
|
+
'.moj-multi-file-upload__dropzone'
|
|
339
|
+
)
|
|
340
|
+
const file = new File(['test content'], 'test.txt', {
|
|
341
|
+
type: 'text/plain'
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
const dropEvent = new Event('drop')
|
|
345
|
+
dropEvent.preventDefault = () => {}
|
|
346
|
+
Object.defineProperty(dropEvent, 'dataTransfer', {
|
|
347
|
+
value: {
|
|
348
|
+
files: [file]
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
dropzone.dispatchEvent(dropEvent)
|
|
353
|
+
|
|
354
|
+
expect(server.requests).toHaveLength(1)
|
|
355
|
+
expect(server.requests[0].url).toBe('/upload')
|
|
356
|
+
expect(server.requests[0].method).toBe('POST')
|
|
357
|
+
|
|
358
|
+
const feedbackContainer = component.querySelector(
|
|
359
|
+
'.moj-multi-file__uploaded-files'
|
|
360
|
+
)
|
|
361
|
+
const successMessage = component.querySelector(
|
|
362
|
+
'.moj-multi-file-upload__success'
|
|
363
|
+
)
|
|
364
|
+
const deleteButton = component.querySelector(
|
|
365
|
+
'.moj-multi-file-upload__delete'
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
// test callbacks
|
|
369
|
+
expect(uploadFileEntryHook).toHaveBeenCalledOnce()
|
|
370
|
+
expect(uploadFileExitHook).toHaveBeenCalledOnce()
|
|
371
|
+
expect(uploadFileExitHook).toHaveBeenCalledAfter(uploadFileEntryHook)
|
|
372
|
+
|
|
373
|
+
// test file present in UI
|
|
374
|
+
expect(feedbackContainer).not.toHaveClass('moj-hidden')
|
|
375
|
+
expect(successMessage).toHaveTextContent('File uploaded successfully')
|
|
376
|
+
expect(deleteButton).toBeInTheDocument()
|
|
377
|
+
expect(deleteButton).toHaveAccessibleName(`Delete test.txt`)
|
|
378
|
+
expect(deleteButton).toHaveValue('test')
|
|
379
|
+
})
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
describe('Uploading multiple files', () => {
|
|
383
|
+
let files
|
|
384
|
+
let input
|
|
385
|
+
const successResponse = {
|
|
386
|
+
success: {
|
|
387
|
+
messageHtml: 'File uploaded successfully',
|
|
388
|
+
messageText: 'File uploaded successfully'
|
|
389
|
+
},
|
|
390
|
+
file: {
|
|
391
|
+
filename: 'test',
|
|
392
|
+
originalname: 'test.txt'
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
beforeEach(() => {
|
|
397
|
+
files = [
|
|
398
|
+
new File(['test content'], 'test-1.txt', { type: 'text/plain' }),
|
|
399
|
+
new File(['test content'], 'test-2.txt', { type: 'text/plain' })
|
|
400
|
+
]
|
|
401
|
+
input = component.querySelector('.moj-multi-file-upload__input')
|
|
402
|
+
input = getByLabelText(component, 'Upload a file')
|
|
403
|
+
|
|
404
|
+
// Configure server response for file upload
|
|
405
|
+
server.respondWith('POST', '/upload', [
|
|
406
|
+
200,
|
|
407
|
+
{ 'Content-Type': 'application/json' },
|
|
408
|
+
JSON.stringify(successResponse)
|
|
409
|
+
])
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
test('handles multiple files', async () => {
|
|
413
|
+
await user.upload(input, files)
|
|
414
|
+
|
|
415
|
+
const feedbackContainer = component.querySelector(
|
|
416
|
+
'.moj-multi-file__uploaded-files'
|
|
417
|
+
)
|
|
418
|
+
const fileRows = component.querySelectorAll('.moj-multi-file-upload__row')
|
|
419
|
+
const successMessages = component.querySelectorAll(
|
|
420
|
+
'.moj-multi-file-upload__success'
|
|
421
|
+
)
|
|
422
|
+
const deleteButtons = component.querySelectorAll(
|
|
423
|
+
'.moj-multi-file-upload__delete'
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
expect(uploadFileEntryHook).toHaveBeenCalledTwice()
|
|
427
|
+
expect(uploadFileExitHook).toHaveBeenCalledTwice()
|
|
428
|
+
|
|
429
|
+
expect(feedbackContainer).not.toHaveClass('moj-hidden')
|
|
430
|
+
expect(fileRows).toHaveLength(2)
|
|
431
|
+
|
|
432
|
+
expect(successMessages[0]).toHaveTextContent('File uploaded successfully')
|
|
433
|
+
expect(deleteButtons[0]).toHaveAccessibleName(`Delete test.txt`)
|
|
434
|
+
expect(deleteButtons[0]).toHaveValue('test')
|
|
435
|
+
expect(successMessages[1]).toHaveTextContent('File uploaded successfully')
|
|
436
|
+
expect(deleteButtons[1]).toHaveAccessibleName(`Delete test.txt`)
|
|
437
|
+
expect(deleteButtons[1]).toHaveValue('test')
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
test('handles multiple file drop', () => {
|
|
441
|
+
const dropzone = component.querySelector(
|
|
442
|
+
'.moj-multi-file-upload__dropzone'
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
const dropEvent = new Event('drop')
|
|
446
|
+
dropEvent.preventDefault = () => {}
|
|
447
|
+
Object.defineProperty(dropEvent, 'dataTransfer', {
|
|
448
|
+
value: {
|
|
449
|
+
files
|
|
450
|
+
}
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
dropzone.dispatchEvent(dropEvent)
|
|
454
|
+
|
|
455
|
+
expect(server.requests).toHaveLength(2)
|
|
456
|
+
expect(server.requests[0].url).toBe('/upload')
|
|
457
|
+
expect(server.requests[0].method).toBe('POST')
|
|
458
|
+
|
|
459
|
+
const feedbackContainer = component.querySelector(
|
|
460
|
+
'.moj-multi-file__uploaded-files'
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
const fileRows = component.querySelectorAll('.moj-multi-file-upload__row')
|
|
464
|
+
const successMessages = component.querySelectorAll(
|
|
465
|
+
'.moj-multi-file-upload__success'
|
|
466
|
+
)
|
|
467
|
+
const deleteButtons = component.querySelectorAll(
|
|
468
|
+
'.moj-multi-file-upload__delete'
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
expect(uploadFileEntryHook).toHaveBeenCalledTwice()
|
|
472
|
+
expect(uploadFileExitHook).toHaveBeenCalledTwice()
|
|
473
|
+
|
|
474
|
+
expect(feedbackContainer).not.toHaveClass('moj-hidden')
|
|
475
|
+
expect(fileRows).toHaveLength(2)
|
|
476
|
+
|
|
477
|
+
expect(successMessages[0]).toHaveTextContent('File uploaded successfully')
|
|
478
|
+
expect(deleteButtons[0]).toHaveAccessibleName(`Delete test.txt`)
|
|
479
|
+
expect(deleteButtons[0]).toHaveValue('test')
|
|
480
|
+
expect(successMessages[1]).toHaveTextContent('File uploaded successfully')
|
|
481
|
+
expect(deleteButtons[1]).toHaveAccessibleName(`Delete test.txt`)
|
|
482
|
+
expect(deleteButtons[1]).toHaveValue('test')
|
|
483
|
+
})
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
describe('Accessibility', () => {
|
|
487
|
+
let file
|
|
488
|
+
let input
|
|
489
|
+
|
|
490
|
+
beforeEach(() => {
|
|
491
|
+
file = new File(['test content'], 'test.txt', {
|
|
492
|
+
type: 'text/plain'
|
|
493
|
+
})
|
|
494
|
+
input = component.querySelector('.moj-multi-file-upload__input')
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
test('status messages are announced to screen readers', async () => {
|
|
498
|
+
await user.upload(input, file)
|
|
499
|
+
|
|
500
|
+
const statusBox = queryByRole(component, 'status')
|
|
501
|
+
expect(statusBox).toHaveTextContent('Uploading files, please wait')
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
test('component has no wcag violations', async () => {
|
|
505
|
+
expect(await axe(document.body)).toHaveNoViolations()
|
|
506
|
+
await user.upload(input, file)
|
|
507
|
+
expect(await axe(document.body)).toHaveNoViolations()
|
|
508
|
+
})
|
|
509
|
+
})
|
|
510
|
+
})
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
# MULTI-SELECT
|
|
3
3
|
========================================================================== */
|
|
4
4
|
|
|
5
|
-
|
|
6
5
|
.moj-multi-select__checkbox {
|
|
7
6
|
display: inline-block;
|
|
8
7
|
padding-left: 0;
|
|
9
8
|
}
|
|
10
9
|
|
|
11
10
|
.moj-multi-select__toggle-label {
|
|
12
|
-
|
|
11
|
+
// stylelint-disable-next-line declaration-no-important
|
|
13
12
|
margin: 0 !important;
|
|
14
|
-
|
|
13
|
+
// stylelint-disable-next-line declaration-no-important
|
|
14
|
+
padding: 0 !important;
|
|
15
|
+
}
|
|
@@ -1,70 +1,75 @@
|
|
|
1
|
-
MOJFrontend.MultiSelect = function(options) {
|
|
2
|
-
this.container = $(options.container)
|
|
1
|
+
MOJFrontend.MultiSelect = function (options) {
|
|
2
|
+
this.container = $(options.container)
|
|
3
3
|
|
|
4
4
|
if (this.container.data('moj-multi-select-initialised')) {
|
|
5
5
|
return
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
this.container.data('moj-multi-select-initialised', true)
|
|
8
|
+
this.container.data('moj-multi-select-initialised', true)
|
|
9
9
|
|
|
10
|
-
const idPrefix = options.id_prefix
|
|
11
|
-
let allId = 'checkboxes-all'
|
|
10
|
+
const idPrefix = options.id_prefix
|
|
11
|
+
let allId = 'checkboxes-all'
|
|
12
12
|
if (typeof idPrefix !== 'undefined') {
|
|
13
|
-
allId = idPrefix
|
|
13
|
+
allId = `${idPrefix}checkboxes-all`
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
this.toggle = $(this.getToggleHtml(allId))
|
|
17
|
-
this.toggleButton = this.toggle.find('input')
|
|
18
|
-
this.toggleButton.on('click', $.proxy(this, 'onButtonClick'))
|
|
19
|
-
this.container.append(this.toggle)
|
|
20
|
-
this.checkboxes = $(options.checkboxes)
|
|
21
|
-
this.checkboxes.on('click', $.proxy(this, 'onCheckboxClick'))
|
|
22
|
-
this.checked = options.checked || false
|
|
23
|
-
}
|
|
16
|
+
this.toggle = $(this.getToggleHtml(allId))
|
|
17
|
+
this.toggleButton = this.toggle.find('input')
|
|
18
|
+
this.toggleButton.on('click', $.proxy(this, 'onButtonClick'))
|
|
19
|
+
this.container.append(this.toggle)
|
|
20
|
+
this.checkboxes = $(options.checkboxes)
|
|
21
|
+
this.checkboxes.on('click', $.proxy(this, 'onCheckboxClick'))
|
|
22
|
+
this.checked = options.checked || false
|
|
23
|
+
}
|
|
24
24
|
|
|
25
25
|
MOJFrontend.MultiSelect.prototype.getToggleHtml = function (allId) {
|
|
26
|
-
let html = ''
|
|
27
|
-
html +=
|
|
28
|
-
|
|
29
|
-
html += ` <
|
|
30
|
-
html +=
|
|
31
|
-
html += '
|
|
32
|
-
html += '</
|
|
33
|
-
|
|
34
|
-
|
|
26
|
+
let html = ''
|
|
27
|
+
html +=
|
|
28
|
+
'<div class="govuk-checkboxes__item govuk-checkboxes--small moj-multi-select__checkbox">'
|
|
29
|
+
html += ` <input type="checkbox" class="govuk-checkboxes__input" id="${allId}">`
|
|
30
|
+
html += ` <label class="govuk-label govuk-checkboxes__label moj-multi-select__toggle-label" for="${allId}">`
|
|
31
|
+
html += ' <span class="govuk-visually-hidden">Select all</span>'
|
|
32
|
+
html += ' </label>'
|
|
33
|
+
html += '</div>'
|
|
34
|
+
return html
|
|
35
|
+
}
|
|
35
36
|
|
|
36
|
-
MOJFrontend.MultiSelect.prototype.onButtonClick = function(e) {
|
|
37
|
-
if(this.checked) {
|
|
38
|
-
this.uncheckAll()
|
|
39
|
-
this.toggleButton[0].checked = false
|
|
37
|
+
MOJFrontend.MultiSelect.prototype.onButtonClick = function (e) {
|
|
38
|
+
if (this.checked) {
|
|
39
|
+
this.uncheckAll()
|
|
40
|
+
this.toggleButton[0].checked = false
|
|
40
41
|
} else {
|
|
41
|
-
this.checkAll()
|
|
42
|
-
this.toggleButton[0].checked = true
|
|
42
|
+
this.checkAll()
|
|
43
|
+
this.toggleButton[0].checked = true
|
|
43
44
|
}
|
|
44
|
-
}
|
|
45
|
+
}
|
|
45
46
|
|
|
46
|
-
MOJFrontend.MultiSelect.prototype.checkAll = function() {
|
|
47
|
-
this.checkboxes.each(
|
|
48
|
-
el
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
MOJFrontend.MultiSelect.prototype.checkAll = function () {
|
|
48
|
+
this.checkboxes.each(
|
|
49
|
+
$.proxy(function (index, el) {
|
|
50
|
+
el.checked = true
|
|
51
|
+
}, this)
|
|
52
|
+
)
|
|
53
|
+
this.checked = true
|
|
54
|
+
}
|
|
52
55
|
|
|
53
|
-
MOJFrontend.MultiSelect.prototype.uncheckAll = function() {
|
|
54
|
-
this.checkboxes.each(
|
|
55
|
-
el
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
MOJFrontend.MultiSelect.prototype.uncheckAll = function () {
|
|
57
|
+
this.checkboxes.each(
|
|
58
|
+
$.proxy(function (index, el) {
|
|
59
|
+
el.checked = false
|
|
60
|
+
}, this)
|
|
61
|
+
)
|
|
62
|
+
this.checked = false
|
|
63
|
+
}
|
|
59
64
|
|
|
60
|
-
MOJFrontend.MultiSelect.prototype.onCheckboxClick = function(e) {
|
|
61
|
-
if(!e.target.checked) {
|
|
62
|
-
this.toggleButton[0].checked = false
|
|
63
|
-
this.checked = false
|
|
65
|
+
MOJFrontend.MultiSelect.prototype.onCheckboxClick = function (e) {
|
|
66
|
+
if (!e.target.checked) {
|
|
67
|
+
this.toggleButton[0].checked = false
|
|
68
|
+
this.checked = false
|
|
64
69
|
} else {
|
|
65
|
-
if(this.checkboxes.filter(':checked').length === this.checkboxes.length) {
|
|
66
|
-
this.toggleButton[0].checked = true
|
|
67
|
-
this.checked = true
|
|
70
|
+
if (this.checkboxes.filter(':checked').length === this.checkboxes.length) {
|
|
71
|
+
this.toggleButton[0].checked = true
|
|
72
|
+
this.checked = true
|
|
68
73
|
}
|
|
69
74
|
}
|
|
70
|
-
}
|
|
75
|
+
}
|