Skip to content
This repository has been archived by the owner on Nov 27, 2020. It is now read-only.

The repeater! #132

Merged
merged 54 commits into from
Dec 30, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
964fedc
enable extended body parsing for nested data
Nov 22, 2019
dd08cff
BREAKING: deprecate `getViewData()` in favour of loadSession
Nov 22, 2019
5518e9e
add context middleware primitives for data
Nov 23, 2019
8fb11a5
update all the control macros to use getData(...) etc
Nov 23, 2019
0acf3d7
use loadFullSession in the confirmation page
Nov 23, 2019
0c28f0d
remove the data.* scope in the confirmation page
Nov 23, 2019
f6f346d
use the new style controls in the personal route
Nov 23, 2019
a20138d
use the new style for the start page
Nov 23, 2019
846c5b4
warn when referencing a non-existent route
Nov 23, 2019
26675e6
add the repeater!
Nov 23, 2019
23fdf75
add a sample route 'addresses' that uses the repeater
Nov 23, 2019
e19d623
locale changes
Nov 23, 2019
eaf000c
add a spec for the context helpers
Nov 23, 2019
5b18e1a
properly implement `isFirstError(relname)`
Nov 23, 2019
38ae1f2
properly use the errorState session key
Nov 25, 2019
c8ceada
expand the contextHelpers spec
Nov 25, 2019
e47fdef
clear the error state on any successful form submission
Nov 25, 2019
95a0a1e
behave better when req.body has falsey values
Dec 2, 2019
0d2bce9
comment
Dec 2, 2019
36eca23
rm a test for an interface that doesn't exist anymore
Dec 2, 2019
25332ad
rm a console.log
Dec 2, 2019
03e9e8d
properly propagate the errors from the session
Dec 2, 2019
654f851
remove validateRouteData
Dec 2, 2019
73c13f5
rm data.helpers.spec.js
Dec 2, 2019
b73a639
add in `mockSession`, a new way to mock the session
Dec 2, 2019
9c5bf35
protect against cases in tests where there is no session
Dec 2, 2019
361d4e5
use console.warn for warnings
Dec 2, 2019
aeefec0
rely on res.locals.firstError in `isFirstError`
Dec 2, 2019
e44a04f
lint
Dec 2, 2019
2a2143a
add a test for route.render()
Dec 2, 2019
0992bfe
add a test for pad
Dec 2, 2019
2e900ad
rm useless conditional
Dec 2, 2019
22ed9d7
use default arguments for pad
Dec 2, 2019
5367470
fix the error list links
Dec 3, 2019
da957c0
better autofocus behaviour
Dec 3, 2019
2497783
add unique identifiers and styling to repeaters
Dec 5, 2019
5106865
allow pressing spacebar to activate the repeat and remove-repeat links
Dec 5, 2019
9578768
standardize on `client.js` for client javascript
Dec 6, 2019
1585efa
update repeater.js to es6 style
Dec 6, 2019
6143c05
delete an overly-brittle test
Dec 6, 2019
771cdc5
better keyboard navigation behaviour
Dec 6, 2019
3abc290
move polyfill scripts to assets/js/
Dec 6, 2019
daae75f
move the only remaining nunjucks script to googleAnalytics.njk
Dec 6, 2019
88a4d27
add assets/js/ to the default module path for client js
Dec 6, 2019
a28a606
add translations for the addresses page and fix confirmation
Dec 6, 2019
10c35e5
remove the push hooks - we will rely on CI
Dec 6, 2019
9892891
disable import-checking in eslint
Dec 9, 2019
f8c323e
use modern variable scoping in repeater.js
Dec 9, 2019
8f1de10
use template strings in repeater.js
Dec 9, 2019
8eb5f9e
rename a confusingly-named helper function
Dec 9, 2019
b5a609a
properly set up the webpack import resolver for eslint
Dec 9, 2019
62b898a
suppress the lgtm bot warning for document.write
Dec 9, 2019
ef0f025
fix and simplify the logic of details-polyfill-detect
Dec 9, 2019
76e9d42
use <button>s for the add / remove buttons
Dec 9, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,12 @@ module.exports = {
'security/detect-non-literal-require': 'off',
'security/detect-non-literal-fs-filename': 'off',
},
overrides: [
{
files: ["routes/*/client.js", "assets/js/*.js"],
settings: {
"import/resolver": "webpack",
},
}
],
}
7 changes: 5 additions & 2 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const helmet = require('helmet')
const path = require('path')
const cookieSession = require('cookie-session')
const cookieSessionConfig = require('./config/cookieSession.config')
const { hasData } = require('./utils')
const { hasData, contextMiddleware } = require('./utils')
const { addNunjucksFilters } = require('./filters')
const csp = require('./config/csp.config')
const csrf = require('csurf')
Expand All @@ -25,7 +25,7 @@ const app = express()

// general app configuration.
app.use(express.json())
app.use(express.urlencoded({ extended: false }))
app.use(express.urlencoded({ extended: true }))
app.use(cookieParser(process.env.app_session_secret))
app.use(require('./config/i18n.config').init)

Expand Down Expand Up @@ -70,6 +70,9 @@ app.locals.hasData = hasData
app.locals.basedir = path.join(__dirname, './views')
app.set('views', [path.join(__dirname, './views')])

// add in helpers for scoped data contexts (used in the repeater)
app.use(contextMiddleware)

app.routes = configRoutes(app, routes, locales)

// view engine setup
Expand Down
15 changes: 15 additions & 0 deletions assets/js/button-link.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// allow for certain links to be activated by pressing the space bar,
// for keyboard-navigating users.
//
// we are using a single global listener because button-link elements
// may be added and removed from the DOM dynamically.
document.addEventListener('keydown', (e) => {
// check when we press the spacebar on a button-link element
if (e.keyCode !== 32) return
if (!e.target.classList.contains('button-link')) return

// trigger the click handlers instead of entering a space
e.preventDefault()
e.target.click()
return false
})
5 changes: 5 additions & 0 deletions assets/js/details-polyfill-detect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
if (typeof Promise !== "function" &&
document.querySelector('details') !== null) {
const script = '<script src="/js/details-element-polyfill.js"><\/script>'
document.write(script) // lgtm[js/eval-like-call]
}
File renamed without changes.
209 changes: 209 additions & 0 deletions assets/js/repeater.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
export const Repeater = (() => {
"use strict"

// state for the module
let repeatedSets

// mapping things that aren't quite arrays
const map = (arr, fn) => Array.prototype.map.call(arr, fn)
const query = (q, el=document) => el.querySelectorAll(q)

const indexBy = (key, arr) => {
const out = {}
arr.forEach(x => { out[x[key]] = x })
return out
}

// a Block is the largest grouping in the repeater. It contains all the repeated
// instances.
class Block {
constructor(el) {
this.name = el.id
this.container = el

this.instances = map(query('.repeater-instance', el), (fieldset, i) => {
const instance = new Instance(this, fieldset, i)
instance.reindex(i)
return instance
})
}

setupListeners() {
// we use one global listener for removal, because the repeat links may
// get added and removed from the DOM, and this means we don't have to
// re-register the event listeners when we clone the nodes.
this.container.addEventListener('click', (evt) => {
if (!evt.target.classList.contains('remove-repeat-link')) return
evt.preventDefault()
const instance = instanceFor(evt.target)
instance && instance.remove()
})

return this
}

repeat() {
if (!this.instances.length) throw new Error(`empty instances, can't repeat!`)

const newIndex = this.instances.length
const newEl = this.instances[0].el.cloneNode(true)
const newInstance = new Instance(this, newEl, newIndex)
newInstance.reindex(newIndex, true)
this.container.appendChild(newEl)
this.instances.push(newInstance)
newInstance.focus()
return newInstance
}
}

const reindexValue = (str, index) => {
// it's always going to be the first [0] or [1] or etc.
return str.replace(/\[\d+\]/, `[${index}]`)
}

const reindexProp = (el, prop, index) => {
const current = el.getAttribute(prop)
if (!current) return
el.setAttribute(prop, reindexValue(current, index))
}

const clearField = (control) => {
if (control.tagName === 'textarea') {
control.innerText = ''
}
else if (control.type === 'radio' || control.type === 'checkbox') {
control.checked = false
}
else {
control.value = null
}
}

// one instance of a repeater
class Instance {
// private functions
constructor(block, el, index) {
this.block = block
this.el = el
this.index = index
}

reindex(newIndex, clear) {
this.index = newIndex

reindexProp(this.el, 'name', newIndex)
this.el.dataset.index = newIndex

query('input,textarea,select', this.el).forEach(control => {
reindexProp(control, 'name', newIndex)
reindexProp(control, 'id', newIndex)
reindexProp(control, 'aria-describedby', newIndex)
if (clear) clearField(control)
})

query('label', this.el).forEach(label => {
reindexProp(label, 'for', newIndex)
})

query('.validation-message', this.el).forEach(error => {
reindexProp(error, 'id', newIndex)
})

query('.remove-repeat-link', this.el).forEach(link => {
reindexProp(link, 'id', newIndex)
})

// special elements that show the user which number they're looking at
query('.repeat-number', this.el).forEach(el => {
el.innerText = `${newIndex+1}`
})

return this
}

clear() {
let first
query('input,textarea,select', this.el).forEach(el => {
if (!first) first = el
clearField(el)
})

if (first) first.focus()
return this
}

focus() {
const first = this.el.querySelector('input,textarea,select')
if (first) first.focus()
}

remove() {
// if we're the last one, we should just empty the fields
if (this.block.instances.length === 1) return this.clear()

// remove the DOM element
this.el.parentElement.removeChild(this.el)

// reindex everything that comes after, updating name and label attributes
for (let i = this.index+1; i < this.block.instances.length; i += 1) {
this.block.instances[i].reindex(i - 1)
}

// remove from the list of instances
this.block.instances.splice(this.index, 1)

// focus the next fieldset if it exists, otherwise the last one
const adjacent = this.block.instances[this.index] ||
this.block.instances[this.index-1]

if (adjacent) adjacent.focus()

return this
}

}

const repeat = (name) => {
repeatedSets[name] && repeatedSets[name].repeat()
}

const instanceFor = (el) => {
const fieldset = el.closest('.repeater-instance')
if (!fieldset) return null

const match = fieldset.name.match(/^\w+/)
if (!match) return null

const blockName = match[0]
const index = parseInt(fieldset.dataset.index)

const block = repeatedSets[blockName]
if (!block) return null

return block.instances[index]
}

const init = () => {
repeatedSets = indexBy('name',
map(query('.repeater'), (el) => {
return new Block(el).setupListeners()
}))

// repeat links are expected to be *outside* the repeater, so can manage
// their own event listeners.
query('.repeat-link').forEach(link => {
link.addEventListener('click', (evt) => {
evt.preventDefault()
repeat(link.dataset.target)
})
})
}

return {
repeat: repeat,
init: init
}
})()

// we are run at the bottom of the page, all necessary elements should have loaded
Repeater.init()
26 changes: 26 additions & 0 deletions assets/scss/components/_repeater.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
.repeater {
}

.repeater-instance {
padding: 10px 20px;
border-left: 5px solid gray;
border-top: 5px solid gray;

> legend {
padding: 10px 15px;
}
}

.remove-repeat-link,
.repeat-link {
width: 25%;
padding: 5px;
}

.remove-repeat-link {
background-color: red;
}

.repeat-link {
background-color: green;
}
3 changes: 2 additions & 1 deletion assets/scss/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@
@import './components/_breakdown-table';
@import './components/_phase-banner';
@import './components/_input-file';
@import './components/skip-link';
@import './components/_repeater';
@import './components/skip-link';
1 change: 1 addition & 0 deletions config/routes.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
const routes = [
{ name: 'start', path: { en: '/start', fr: '/debut' } },
{ name: 'personal', path: { en: '/personal', fr: '/personnel' } },
{ name: 'addresses', path: { en: '/addresses', fr: '/addresses' } },
{ name: 'confirmation', path: '/confirmation' },
]

Expand Down
9 changes: 9 additions & 0 deletions filters/error.helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const errorHelpers = env => {
env.addFilter('errorToName', (str) => {
return str.replace(/[.](\w+)/g, '[$1]')
})
}

module.exports = {
errorHelpers
}
2 changes: 2 additions & 0 deletions filters/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
const { spreadParams } = require('./spread.params')
const { errorHelpers } = require('./error.helpers')

const addNunjucksFilters = env => {
spreadParams(env)
errorHelpers(env)
}

module.exports = {
Expand Down
19 changes: 16 additions & 3 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,21 @@
"start.submit": "Let's Go",
"start.title": "Node Starter App",
"wordmark.alt": "Symbol of the Government of Canada",
"caught this error": "caught this error",
"Cannot read property 'match' of undefined": "Cannot read property 'match' of undefined",
"required": "required",
"Government of Canada": "Government of Canada",
"required": "required"
"addresses.title": "Enter all addresses from the last 2 years",
"addresses.intro": "This information is needed because lorem ipsum dolor sit amet",
"addresses.number": "Address #%s",
"addresses.street": "Street address",
"addresses.type": "Address Type",
"addresses.type.house": "House",
"addresses.type.apartment": "Apartment",
"addresses.type.other": "Other",
"addresses.features": "Features",
"addresses.features.laundry": "Laundry",
"addresses.features.kitchen": "Kitchen",
"addresses.features.yard": "Yard",
"addresses.remove": "[- remove]",
"addresses.add-another": "[+ add another]",
"errors.address.length": "Please fill in an address"
}