@defra/flood-webchat 0.0.1-alpha.9 → 0.0.1-beta.2

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.
Files changed (37) hide show
  1. package/README.md +1 -83
  2. package/dist/client.js +445 -0
  3. package/dist/client.js.map +1 -0
  4. package/dist/server.js +362 -0
  5. package/dist/server.js.map +1 -0
  6. package/docs/development-guide.md +1 -2
  7. package/jest.config.mjs +29 -0
  8. package/main.scss +5 -741
  9. package/package.json +18 -13
  10. package/src/client/components/availability/availability.jsx +101 -0
  11. package/src/client/components/availability/availability.scss +89 -0
  12. package/src/client/index.jsx +16 -0
  13. package/src/client/lib/check-availability.js +7 -0
  14. package/src/client/lib/classnames.js +1 -0
  15. package/src/client/lib/external-stores.js +4 -0
  16. package/src/client/lib/external-sync-store.js +42 -0
  17. package/src/server/index.js +11 -6
  18. package/src/server/{client.js → lib/client.js} +32 -12
  19. package/src/server/{utils.js → lib/utils.js} +8 -13
  20. package/webpack.config.mjs +32 -0
  21. package/assets/audio/notification.mp3 +0 -0
  22. package/babel.config.cjs +0 -3
  23. package/dist/templates.js +0 -554
  24. package/src/client/index.js +0 -14
  25. package/src/client/lib/availability.js +0 -75
  26. package/src/client/lib/config.js +0 -17
  27. package/src/client/lib/keyboard.js +0 -149
  28. package/src/client/lib/notification.js +0 -59
  29. package/src/client/lib/nunjucks.js +0 -3
  30. package/src/client/lib/panel.js +0 -293
  31. package/src/client/lib/provider.js +0 -11
  32. package/src/client/lib/skiplink.js +0 -27
  33. package/src/client/lib/state.js +0 -82
  34. package/src/client/lib/transcript.js +0 -34
  35. package/src/client/lib/utils.js +0 -190
  36. package/src/client/lib/webchat.js +0 -1075
  37. package/src/style/_mixins.scss +0 -13
@@ -1,149 +0,0 @@
1
- import Utils from './utils.js'
2
-
3
- class Keyboard {
4
- static _isKeyboard = false
5
-
6
- static init (state) {
7
- this._state = state
8
- this._detectFocus()
9
-
10
- Utils.listenForDevice('mobile', this._updateInert.bind(this))
11
- }
12
-
13
- static _detectFocus () {
14
- document.addEventListener('keydown', e => {
15
- this._isKeyboard = true
16
- if (e.key === 'Tab' || e.key === 'Escape' || e.key === 'Esc') {
17
- this._constrainFocus(e)
18
- }
19
- })
20
-
21
- document.addEventListener('keyup', e => {
22
- this._isKeyboard = true
23
- if (e.key === 'Tab') {
24
- const el = this._focusParent()
25
- if (el) {
26
- this._toggleInert(el)
27
- }
28
- }
29
- })
30
-
31
- document.addEventListener('pointerdown', e => {
32
- this._isKeyboard = false
33
- })
34
-
35
- document.addEventListener('focus', e => {
36
- const isScope = !!e.target.closest('#wc-availability, #wc-panel')
37
- if (!isScope) {
38
- return
39
- }
40
- const target = e.target.hasAttribute('data-wc-focus-parent') ? e.target.parentNode : e.target
41
- target.classList.toggle('wc-focus-visible', this._isKeyboard)
42
- }, true)
43
-
44
- document.addEventListener('blur', e => {
45
- const target = e.target.hasAttribute('data-wc-focus-parent') ? e.target.parentNode : e.target
46
- target.classList.remove('wc-focus-visible')
47
- }, true)
48
- }
49
-
50
- static _isFocusable (el) {
51
- const tagNames = ['A', 'BUTTON', 'INPUT', 'TEXTAREA', 'SELECT']
52
- const isHidden = el.hidden || el.style.display === 'none'
53
- const isTextbox = el.getAttribute('role') === 'textbox'
54
-
55
- return (tagNames.includes(el.tagName) || el.tabIndex >= 0 || isTextbox) && !isHidden
56
- }
57
-
58
- static _focusParent () {
59
- const el = document.querySelector('[data-wc]')
60
-
61
- if (el && !document.activeElement.closest('[data-wc]')) {
62
- el.focus()
63
- }
64
-
65
- return el
66
- }
67
-
68
- static _constrainFocus (e) {
69
- const el = this._focusParent()
70
- if (!el) {
71
- return
72
- }
73
-
74
- const focusableEls = this._getFocusableEls(el)
75
- const firstFocusableEl = focusableEls[0]
76
- const lastFocusableEl = focusableEls[focusableEls.length - 1]
77
-
78
- if (e.shiftKey) {
79
- if (document.activeElement === firstFocusableEl) {
80
- lastFocusableEl.focus()
81
- e.preventDefault()
82
- }
83
- } else {
84
- if (document.activeElement === lastFocusableEl) {
85
- firstFocusableEl.focus()
86
- e.preventDefault()
87
- }
88
- }
89
- }
90
-
91
- static _toggleInert (el) {
92
- const inert = document.querySelectorAll('[data-wc-inert]')
93
- for (let i = 0; i < inert.length; i++) {
94
- const el = inert[i]
95
- el.removeAttribute('aria-hidden')
96
- el.removeAttribute('data-wc-inert')
97
- }
98
- if (el) {
99
- while (el.parentNode && el !== document.body) {
100
- let sibling = el.parentNode.firstChild
101
- while (sibling) {
102
- if (sibling.nodeType === 1 && sibling !== el) {
103
- if (!sibling.hasAttribute('aria-hidden')) {
104
- sibling.setAttribute('aria-hidden', true)
105
- sibling.setAttribute('data-wc-inert', '')
106
- }
107
- }
108
- sibling = sibling.nextSibling
109
- }
110
- el = el.parentNode
111
- }
112
- }
113
- }
114
-
115
- static _updateInert () {
116
- const isOpen = this._state.isOpen
117
- const activeElement = document.activeElement
118
- const isWithinDialog = activeElement && !!document.activeElement.closest('[data-wc][role="dialog"][open]')
119
- const containerEl = document.querySelector('[data-wc]')
120
- if (!isWithinDialog) {
121
- this._toggleInert(isOpen ? containerEl : null)
122
- }
123
- }
124
-
125
- static _getFocusableEls (el) {
126
- const selectors = [
127
- 'a[href]:not([disabled])',
128
- 'button:not([disabled])',
129
- 'textarea:not([disabled])',
130
- 'input:not([disabled])',
131
- 'select:not([disabled])',
132
- '*[tabindex="0"]:not([disabled])'
133
- ]
134
- let focusableEls = Array.from(el.querySelectorAll(selectors.join(',')))
135
- focusableEls = focusableEls.filter(e => !e.closest('[hidden]') && !e.closest('[aria-hidden="true"]'))
136
- return focusableEls
137
- }
138
-
139
- static toggleInert (el) {
140
- this._toggleInert(el)
141
- }
142
-
143
- static getFirstFocusableEl () {
144
- const focusableEls = this._getFocusableEls(document)
145
- return focusableEls.length ? focusableEls[0] : null
146
- }
147
- }
148
-
149
- export default Keyboard
@@ -1,59 +0,0 @@
1
- export default class Notification {
2
- constructor (url) {
3
- this._url = url
4
-
5
- // Create context
6
- const context = new (window.AudioContext || window.webkitAudioContext)()
7
- // context.addEventListener('statechange', () => {
8
- // console.log('Audio: ', context.state)
9
- // })
10
- this._context = context
11
-
12
- // Unlock audio
13
- this._unlockAudioContext()
14
-
15
- // Load buffer
16
- this._init(url)
17
- }
18
-
19
- _init () {
20
- const context = this._context
21
- const url = this._url
22
-
23
- fetch(url)
24
- .then(response => response.arrayBuffer())
25
- .then(buffer => context.decodeAudioData(buffer))
26
- .then(decodedData => {
27
- this._buffer = decodedData
28
- })
29
- .catch(console.error)
30
- }
31
-
32
- _unlockAudioContext () {
33
- const context = this._context
34
- if (context.state === 'suspended') {
35
- const events = ['touchstart', 'touchend', 'mousedown', 'wheel', 'keydown', 'click']
36
- const unlock = e => {
37
- events.forEach(event => {
38
- document.body.removeEventListener(event, unlock)
39
- })
40
- context.resume()
41
- }
42
- events.forEach(event => {
43
- document.body.addEventListener(event, unlock, false)
44
- })
45
- }
46
- }
47
-
48
- playSound () {
49
- const context = this._context
50
- const buffer = this._buffer
51
- console.log('Play sound (context state: ', context.state, ')')
52
-
53
- // Create source
54
- const source = context.createBufferSource()
55
- source.buffer = buffer
56
- source.connect(context.destination)
57
- source.start()
58
- }
59
- }
@@ -1,3 +0,0 @@
1
- import '../../../dist/templates.js'
2
- import nunjucks from 'nunjucks/browser/nunjucks-slim.js'
3
- export default nunjucks
@@ -1,293 +0,0 @@
1
- import { Button, CharacterCount } from 'govuk-frontend'
2
- import Utils from './utils.js'
3
- import Config from './config.js'
4
- import Keyboard from './keyboard.js'
5
- import nunjucks from './nunjucks.js'
6
-
7
- class Panel {
8
- constructor () {
9
- this._container = null
10
- this._timeout = Utils.getDuration(Config.timeout)
11
- }
12
-
13
- _initComponents (el) {
14
- // Initialise GOV.UK components
15
- const buttons = el.querySelectorAll('[data-module="govuk-button"]')
16
- for (const button of buttons) {
17
- new Button(button).init()
18
- }
19
- const characterCounts = el.querySelectorAll('[data-module="govuk-character-count"]')
20
- for (const characterCount of characterCounts) {
21
- new CharacterCount(characterCount).init()
22
- }
23
- // Initialise autoresize
24
- const textbox = el.querySelector('[data-wc-textbox]')
25
- if (textbox) {
26
- Utils.autosize(textbox)
27
- }
28
- }
29
-
30
- _updateMessagesLabel (total) {
31
- const body = this.body
32
- const label = `Conversation with ${total} message${total > 1 ? 's' : ''}`
33
- body.setAttribute('aria-label', label)
34
- }
35
-
36
- _scrollToLatest (isScroll) {
37
- if (!isScroll) {
38
- return
39
- }
40
-
41
- // Scroll to latest
42
- this.body.scrollTop = this.body.scrollHeight
43
- }
44
-
45
- create (state, addEvents) {
46
- console.log('panel.create()')
47
-
48
- const model = {
49
- ...state
50
- }
51
- const html = nunjucks.render('panel.html', { model })
52
- document.body.insertAdjacentHTML('beforeend', html)
53
-
54
- const container = document.querySelector('[data-wc]')
55
- const header = container.querySelector('[data-wc-header]')
56
- const body = container.querySelector('[data-wc-body]')
57
- const footer = container.querySelector('[data-wc-footer]')
58
- this.container = container
59
- this.header = header
60
- this.body = body
61
- this.footer = footer
62
-
63
- // Move focus to container and set inert
64
- container.focus()
65
- Keyboard.toggleInert(container)
66
-
67
- Utils.listenForDevice('mobile', this.setAttributes.bind(this, state))
68
-
69
- // Message events
70
- container.addEventListener('keyup', e => {
71
- if (e.target.hasAttribute('data-wc-textbox')) {
72
- const textbox = e.target
73
- const label = textbox.previousElementSibling
74
- // Conditionally show label
75
- Utils.toggleLabel(label, e.key, textbox)
76
- // Conditionally submit form
77
- Utils.submit(e, textbox)
78
- }
79
- })
80
- container.addEventListener('keydown', e => {
81
- if (e.target.hasAttribute('data-wc-textbox')) {
82
- const textbox = e.target
83
- const label = textbox.previousElementSibling
84
- // Conditionally hide label
85
- Utils.toggleLabel(label, e.key, textbox)
86
- // Conditionally suppress enter
87
- Utils.suppressEnter(e, textbox)
88
- }
89
- })
90
- container.addEventListener('change', e => {
91
- if (e.target.hasAttribute('data-wc-textbox')) {
92
- console.log('change', e.target)
93
- const textbox = e.target
94
- const label = textbox.previousElementSibling
95
- Utils.toggleLabel(label, null, textbox)
96
- }
97
- }, true)
98
-
99
- // Add panel events
100
- addEvents()
101
- }
102
-
103
- update (state, error) {
104
- console.log('panel.updateAll()')
105
-
106
- const container = document.getElementById('wc-panel')
107
- if (!container) {
108
- return
109
- }
110
-
111
- // Update labelledyby
112
- this.container.setAttribute('aria-labelledyby', error ? 'wc-title wc-subtitle wc-error' : 'wc-title wc-subtitle')
113
-
114
- this.updateHeader(state)
115
- this.updateBody(state, error)
116
- this.updateFooter(state)
117
- }
118
-
119
- updateHeader (state) {
120
- console.log('panel.updateHeader()')
121
-
122
- const container = document.getElementById('wc-panel')
123
- if (!container) {
124
- return
125
- }
126
-
127
- // Model
128
- const model = {
129
- ...state
130
- }
131
-
132
- const header = this.header
133
- header.innerHTML = nunjucks.render('header.html', { model })
134
-
135
- this._initComponents(header)
136
- }
137
-
138
- updateBody (state, error) {
139
- console.log('panel.updateBody()')
140
-
141
- const container = document.getElementById('wc-panel')
142
- if (!container) {
143
- return
144
- }
145
-
146
- // Model
147
- const model = {
148
- ...state,
149
- error,
150
- timeout: this._timeout
151
- }
152
-
153
- const body = this.body
154
- body.innerHTML = nunjucks.render('body.html', { model })
155
-
156
- // Toggle body keyboard accessibility
157
- const isViewOpen = state.view === 'OPEN'
158
- if (isViewOpen) {
159
- body.tabIndex = 0
160
- const total = state.messages.length
161
- this._updateMessagesLabel(total)
162
- this._scrollToLatest(state.isScroll)
163
- } else {
164
- body.removeAttribute('tabindex')
165
- body.removeAttribute('aria-label')
166
- }
167
-
168
- this._initComponents(body)
169
- }
170
-
171
- updateFooter (state) {
172
- console.log('panel.updateFooter()')
173
-
174
- const container = document.getElementById('wc-panel')
175
- if (!container) {
176
- return
177
- }
178
-
179
- // Model
180
- const model = {
181
- ...state
182
- }
183
-
184
- const footer = this.footer
185
- footer.innerHTML = nunjucks.render('footer.html', { model })
186
-
187
- this._initComponents(footer)
188
- }
189
-
190
- addMessage (state, message) {
191
- const list = this.container.querySelector('[data-wc-list]')
192
- if (!list) {
193
- return
194
- }
195
-
196
- list.insertAdjacentHTML('beforeend', message.html)
197
-
198
- // Update message label
199
- const total = state.messages.length
200
- this._updateMessagesLabel(total)
201
-
202
- // Scroll messages
203
- this._scrollToLatest(state.isScroll)
204
- }
205
-
206
- toggleAgentTyping (state, isTyping) {
207
- const list = this.body.querySelector('[data-wc-list]')
208
- if (!list) {
209
- return
210
- }
211
-
212
- // Add or remove elements from list
213
- const el = list.querySelector('[data-wc-agent-typing]')
214
- if (isTyping) {
215
- list.insertAdjacentHTML('beforeend', nunjucks.render('agent-typing.html', {
216
- model: {
217
- name: state.assignee
218
- }
219
- }))
220
-
221
- // Scroll to show new elements
222
- this._scrollToLatest(state.isScroll)
223
- } else if (el) {
224
- el.remove()
225
- }
226
- }
227
-
228
- toggleTimeout (state, hasTimeout) {
229
- const container = this.container
230
- const timeout = container.querySelector('[data-wc-timeout]')
231
-
232
- if (hasTimeout) {
233
- const list = container.querySelector('[data-wc-list]')
234
-
235
- // *** If we have the list but don't already have the timeout markup
236
- if (list && !timeout) {
237
- list.insertAdjacentHTML('afterend', `
238
- <div class="wc-timeout" data-wc-timeout>
239
- <div class="wc-timeout__inner">
240
- <div class="wc-timeout__message">Webchat will end in <span data-wc-countdown>${this._timeout} seconds</span></div>
241
- </div>
242
- <a href="#" class="wc-cancel-timeout-btn" data-wc-cancel-timeout>Continue webchat</a>
243
- </div>
244
- `)
245
- this._scrollToLatest(state.isScroll)
246
-
247
- // Clear timeout
248
- const clearBtn = container.querySelector('[data-wc-cancel-timeout]')
249
- clearBtn.addEventListener('click', e => {
250
- e.preventDefault()
251
- this._resetTimeout()
252
- })
253
- }
254
- } else if (timeout) {
255
- timeout.remove()
256
- }
257
- }
258
-
259
- setAttributes (state) {
260
- const container = this.container
261
- if (!container) {
262
- return
263
- }
264
-
265
- const isFullscreen = state.isMobile && state.isOpen
266
- const root = document.getElementsByTagName('html')[0]
267
- root.classList.toggle('wc-html', isFullscreen)
268
- document.body.classList.toggle('wc-body', isFullscreen)
269
- container.setAttribute('aria-modal', true)
270
-
271
- const backBtn = container.querySelector('[data-wc-back-btn]')
272
- const hideBtn = container.querySelector('[data-wc-hide-btn]')
273
- const closeBtn = container.querySelector('[data-wc-close-btn]')
274
-
275
- if (backBtn) {
276
- backBtn.hidden = !isFullscreen
277
- }
278
- if (hideBtn) {
279
- hideBtn.hidden = isFullscreen
280
- }
281
- if (closeBtn) {
282
- closeBtn.hidden = isFullscreen
283
- }
284
-
285
- // Toggle textbox behaviour
286
- const textbox = container.querySelector('[data-wc-textbox]')
287
- if (textbox) {
288
- textbox.toggleAttribute('data-wc-enter-submit', !state.isMobile)
289
- }
290
- }
291
- }
292
-
293
- export default Panel
@@ -1,11 +0,0 @@
1
- // 'use strict'
2
- //
3
- // import { ChatSdk, EnvironmentName, ChatEvent } from '@nice-devone/nice-cxone-chat-web-sdk'
4
- //
5
- // class Provider {
6
- // static authorise () {
7
- // return null
8
- // }
9
- // }
10
- //
11
- // export default Provider
@@ -1,27 +0,0 @@
1
- import nunjucks from './nunjucks.js'
2
-
3
- class Skiplink {
4
- constructor () {
5
- this.target = document.querySelector('a[data-module="govuk-skip-link"]')
6
- this.skiplink = null
7
- }
8
-
9
- toggle (hasSkip) {
10
- const target = this.target
11
- const skiplink = this.skiplink
12
- if (!target) {
13
- return
14
- }
15
-
16
- if (hasSkip && !skiplink) {
17
- // Add skiplink
18
- target.insertAdjacentHTML('afterend', nunjucks.render('skiplink.html'))
19
- this.skiplink = target.nextElementSibling
20
- } else if (skiplink && !hasSkip) {
21
- // Remove skiplink
22
- this.skiplink.remove()
23
- }
24
- }
25
- }
26
-
27
- export default Skiplink
@@ -1,82 +0,0 @@
1
- import Utils from './utils.js'
2
-
3
- class State {
4
- constructor (openChat, closeChat) {
5
- const threadId = window.localStorage.getItem('THREAD_ID')
6
- const isBack = window.sessionStorage.getItem('IS_BACK') === 'true'
7
- const isOpen = window.location.hash === '#webchat'
8
- const view = threadId ? 'OPEN' : null
9
-
10
- // Settings
11
- const settings = window.localStorage.getItem('SETTINGS')
12
- const hasAudio = settings ? settings.split(',')[0] : true
13
- const isScroll = settings ? settings.split(',')[1] : true
14
-
15
- // Public properties
16
- this.threadId = threadId
17
- this.availability = null
18
- this.status = null
19
- this.view = view
20
- this.assignee = null
21
- this.unseen = 0
22
- this.isAuthorised = false
23
- this.hasAudio = hasAudio
24
- this.isScroll = isScroll
25
- this.isMobile = true
26
- this.isBack = isBack
27
- this.isOpen = isOpen
28
- this.messages = []
29
- this.name = null
30
- this.question = null
31
-
32
- // Private methods (callbacks)
33
- this._openChat = openChat
34
- this._closeChat = closeChat
35
-
36
- // Help with browser back behaviour
37
- if (window.history.length <= 1) {
38
- this._isBack = false
39
- window.sessionStorage.removeItem('IS_BACK')
40
- }
41
-
42
- // We need to toggle some attributes depending on screen size
43
- Utils.listenForDevice('mobile', (isMobile) => { this.isMobile = isMobile })
44
-
45
- // Events
46
- window.addEventListener('popstate', this._popstate.bind(this))
47
- }
48
-
49
- _popstate (e) {
50
- if (window.location.hash === '#webchat') {
51
- this._openChat(e)
52
- } else {
53
- this._closeChat(e)
54
- }
55
- }
56
-
57
- replaceState () {
58
- this.isOpen = false
59
- const url = window.location.href.split('#')[0]
60
- window.history.replaceState(null, null, url)
61
- }
62
-
63
- pushState () {
64
- this._isOpen = true
65
- this._isBack = true
66
- const url = `${window.location.href.split('#')[0]}#webchat`
67
- window.history.pushState({ view: 'webchat', isBack: true }, '', url)
68
- window.sessionStorage.setItem('IS_BACK', true)
69
- }
70
-
71
- back () {
72
- const offsetY = window.scrollY
73
- window.history.back()
74
- if (!this._isMobile) {
75
- window.addEventListener('scroll', e => {
76
- window.scrollTo(0, offsetY)
77
- }, { once: true })
78
- }
79
- }
80
- }
81
-
82
- export default State
@@ -1,34 +0,0 @@
1
- 'use strict'
2
-
3
- class Transcript {
4
- constructor (messages) {
5
- const text = this._buildString(messages)
6
- this.data = `data:text/plain;charset=utf-8,${encodeURIComponent(text)}`
7
- }
8
-
9
- _buildString (messages) {
10
- const dateNow = this._formatDate(new Date())
11
- let string = `Floodline webchat transcript at ${dateNow}\n\n`
12
- for (let i = 0; i < messages.length; i++) {
13
- const author = messages[i].direction === 'inbound' ? messages[i].user : `${messages[i].assignee} (Floodline adviser)`
14
- const date = this._formatDate(messages[i].createdAt)
15
- string += `${author} at ${date}\n${messages[i].text}\n\n`
16
- }
17
- string = string.replace(/<a\b[^>]*>/i, '').replace(/<\/a>/i, '')
18
- return string
19
- }
20
-
21
- _formatDate (datetime) {
22
- let hours = datetime.getHours()
23
- let minutes = datetime.getMinutes()
24
- minutes = `${minutes < 10 ? '0' : ''}${minutes}`
25
- const ampm = hours >= 12 ? 'pm' : 'am'
26
- hours %= 12
27
- hours = hours || 12
28
- const time = `${hours}:${minutes}${ampm}`
29
- const date = datetime.toLocaleString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })
30
- return `${time}, ${date}`
31
- }
32
- }
33
-
34
- export default Transcript