Single Page Voter is a tool for performing small in person votes. Do not use this for anything to serious.
While implementing this tool I have decided to:
Use literate programming as my intention is to convey more clearly my design choices to the reader. For install instructions see the literate GitHub
Use Vanilla JS to convey my understanding of the fundamentals and the browser.
Design for bleeding edge browsers only. Not cleaning up event listeners when an element is removed.
Not focus on tools like frameworks, linters, prettifiers, transpilers and bundlers.
See single page voter in action over here, or keep reading to see how and why I have implemented the way I did.
Whenever you see text styled in this manner, it takes you into my mind. Most of the consideration does not end up as working code. These blocks explain some of my thought process and design choices.
Like any good frontend web application we start with a simple HTML template, then the code, followed by styling.
<!DOCTYPE html> <html> <head> <title>Single page voter</title> {Pure CSS Link, 23} <style> {Single Page Voter CSS rules, 24} </style> </head> <body> <h1>Single Page Voter</h1> {Single Page Voter HTML, 3} </body> <script> {Single Page Voter Script, 4} </script> </html>
Notice that we define the script tag after the body. By doing this the Voter HTML has been registered and is accessible through the DOM API. It is also possible to listen to load events but I find this approach requiring the least amount of effort and code.
All but the first element is generated using Javascript. This div contains an
id which allows us to reference it from JS using document.getElementById
.
Why not append directly to the body? With the
id
approach you have more control in the HTML as to where the component should be rendered. It makes it easier when combining other components in the HTML.
We will use an IIFE. This allows one to define variables without changing any global variables/objects.
To prevent naming collisions within our own IIFE, we could define a namespace object. This can come in handy when in the future you or someone else pulls in a dependency in the global environment or the IIFE's code block. However, I did not do this. If a project requires this type of code separation it is better to use modules and bundling. Let's look at that IIFE.
(function() { {State Utility, 7} {Helpers, 18} {Initialize Components, 10} })()
Used in section 2
Notice that the code is split up into a state utility, some helpers and the initialization of the components.
I could also have made a main or app function and then just call it. I would get the same benefit of the IIFE. I would however mutate the global environment with a reference to the main/app function.
Used to make the bar chart look prettier.
We will use this list of fruits later in the program.
We have some state to keep around. For this we make a little state utility. It allows transforming the state and listening to state changes.
function state(initialState) { let state = initialState(); const callbacks = {} let id = 0 function transform(transformFn) { state = transformFn(state) // Call all listener callbacks when transform occurs. Object.keys(callbacks) .forEach(id => callbacks[id](state)) } function addListener(callback) { id = id + 1 callback(state) callbacks[id] = callback return id } function removeListener(id) { delete callbacks[id] } function reset() { transform(() => initialState()) } return { reset, transform, addListener, removeListener, } }
I went for the simplest thing I could think of.
I have chosen a tool I find very useful which is Perl's prove. It is
a test harness that will run all ./t/*.t
files. It expects these scripts to
output TAP.
To make this possible we need a JS library that supports tap. NPM's tape should do the job.
We create the package.json
and register tape as one of the dev dependencies.
We also register prove
as the test script.
{ "name": "simple-page-voter", "version": "1.0.0", "description": "Vote on a single page", "author": "Bas Huis", "license": "GNU General Public License 3.0", "scripts": { "test": "prove" }, "devDependencies": { "tape": "^5.0.1" } }
Because we are using lit, we can just concat the State Utility section directly into our file. It is more common to see this utility be defined in a node module instead.
{Tape Script, 8} {State Utility, 7} const always = v => () => v test('Initial state stays the same after transform (reference)', t => { const init = {} const myState = state(always(init)) myState.transform(state => { t.equals(state, init) return state }) myState.transform(state => { t.equals(state, init) t.end() }) }) test('Event listener is called on register', t => { const init = {} const myState = state(always(init)) myState.addListener(state => { t.equals(init, state) t.end() }) }) test('Event listener is called on state transform', t => { const init = {} const myState = state(always(init)) t.plan(2) myState.addListener(state => { t.equals(state, init) }) myState.transform(v => v) t.end() }) test('Event listener is not called after remove', t => { const init = {} const myState = state(always(init)) t.plan(1) const id = myState.addListener(state => { t.equals(init, state) }) myState.removeListener(id) myState.transform(v => v) t.end() }) test('Reset the state', t => { const initFn = always({ a: 1 }) const myState = state(initFn) const changed = {a: 2} myState.transform(state => changed) myState.removeListener( myState.addListener(state => t.deepEquals(state, changed))) myState.reset() myState.addListener(state => t.deepEquals(state, initFn())) t.end() })
Thinking of possible states an application can run into and writing tests for these cases is something that requires skill and time. I find generative and property based testing a great tool for putting your application to the test. For the sake of time I'll leave that for another day.
These tests can be run by executing the ./t/state.t
file or by running
the prove or npm t
command. Do make sure to first install the tape
dependency with npm i
.
We have some tests for the state utility and we are ready to start initializing the components.
The application consists out of three columns. Within these columns we can also sub divide the UI into simpler parts. From now on we name these columns and parts components.
Before we have a look at the individual components, we'll write the code necessary to wire up the components and append them to the page. You can consider this code block the main of the app.
// common components {Input Component, 11} // first column {Edit Poll Component, 12} // second column {Vote Component, 21} // third column {Result Component, 22} function initialState() { {Fruits Array, 6} return { optionInput: '', question: 'What fruit is the best fruit?', options: fruits, votes: {}, } } const myState = state(initialState) {Computed State, 13} const appElem = document.getElementById('poll-creator') // columns const editPoll = appElem.appendChild(editPollComponent(myState)) const vote = appElem.appendChild(voteComponent(myState)) const result = appElem.appendChild(resultComponent(myState)) {Hide Vote And Result Column, 10} {Reset Votes On Poll Change, 10} appElem.classList.add('pure-g') editPoll.classList.add('pure-u-1') vote.classList.add('pure-u-1') result.classList.add('pure-u-1') editPoll.classList.add('pure-u-sm-1-3') vote.classList.add('pure-u-sm-1-3') result.classList.add('pure-u-sm-1-3')
Used in section 4
Hide the vote and result column when there is no question or enough options defined.
myState.addListener(state => { const displayType = myState.computed.canVote(state) && state.question ? 'block' : 'none' vote.style.display = displayType result.style.display = displayType })
Whenever the poll changes we want to reset the votes. We define this in a state listener in order to have the checking and changing all in one place.
Previously I had several
state.votes = {}
peppered throughout some callbacks. This goes against the DRY principle which makes it harder to maintain the code.
function resetVotesOnPollChange() { let oldValue return ({options, question}) => { const newValue = JSON.stringify({ options: options, question: question }) if (oldValue === undefined) oldValue = newValue if (oldValue !== newValue) { oldValue = newValue myState.transform(state => { state.votes = {} return state }) } } } myState.addListener(resetVotesOnPollChange())
I could have used an deep equality function or better suited data types for checking if the poll information has changed. Using JSON.stringify was however the most obvious approach.
One of the requirements is that all input fields have an 80 character limit. We create a component with this common behavior and use it whenever we create an input field.
function inputComponent() { const input = document.createElement('input') input.addEventListener('input', event => { input.value = input.value.substring(0, 80) }) return input }
Used in section 10
After writing this common component, I considered to instead define the input value limit using one or more state listeners. This would result in adding some extra code because all input fields that use this limit would require an event listener to update the value of that element with the sub-stringed value. The
inputComponent
approach is less code and solves the requirement for this project sufficiently.
The first column is the poll editing form. The component is able to transform the poll. Just like every "component function", it returns an element.
function editPollComponent(myState) { const form = document.createElement('form') const p = document.createElement('p') const question = inputComponent() const options = optionsComponent(myState) const optionsCap = optionsCapComponent(myState) const reset = document.createElement('button') reset.textContent = 'reset' myState.addListener(state => { question.value = state.question }) function updateQuestion(event) { myState.transform(state => { state.question = event.target.value return state }) } {Options Component, 15} {Options Cap Component, 12} {Add Option Component, 17} reset.addEventListener('click', event => { event.preventDefault() myState.reset() return false }) question.addEventListener('input', updateQuestion) // Prevent enter from pressing any buttons. question.addEventListener('keypress', event => { if ((event.which || event.keyCode || event.charCode) === 13) event.preventDefault() }) p.appendChild(question) form.appendChild(p) form.appendChild(options) form.appendChild(addOptionComponent(myState)) form.appendChild(optionsCap) form.appendChild(reset) return form }
Used in section 10
Then the component that shows how many options one has created and how many one is allowed to make. A basic component that demo's string interpolation.
function optionsCapComponent(myState) { const elem = document.createElement('div') myState.addListener(state => { const count = myState.computed.optionsCount(state) elem.innerHTML = `${count}/10 possible answers` }) return elem }
First I was using the older
'string' + 'string'
string interpolation. In this case both approaches would suffice.
Some values are derived from the state. We name these values computed. Here we list all the helpers for computed values.
myState.computed = { options(state) { return compact(state.options) }, canAddOption(state) { return (this.options(state).length < 10) && (state.optionInput !== '') }, optionsCount(state) { return this.options(state).length }, optionsWithIndex(state) { return compactWithIndex(state.options) }, totalVotes(state) { return Object.values(state.votes).reduce((a, b) => a + b, 0) }, maxVotes(state) { return Object.values(state.votes) .reduce((a, b) => Math.max(a, b), 0) || 0 }, optionVotes(state) { return toPairs(state.votes).map(optionVote => [...optionVote, state.options[optionVote[0]]]) }, canVote(state) { return this.optionsCount(state) >= 2 }, }
Used in section 10
I have considered defining the state transform callbacks in a similar object. Were the program to grow further, it would make sense to isolate all state related functionality from the components. This would improve the testability.
The computed helpers depend on the toPairs helper. Here the implementation and some tests.
The goal of Options Component
is to list all the options and allow removal of
one of the options. For now each option has its own event listener. This can be
improved by using event bubbling and registering an event listener on a parent
element. This would be more memory efficient in cases where one would have many
elements. In this case we have a max of 10 options.
function optionsComponent(myState) {
const ul = document.createElement('ul')
{Edit Option Component, 16}
const mapEditOptionsComponent = mapComponents(
editOptionComponent.bind(null, myState) // partial application
)
myState.addListener((state) => {
mapEditOptionsComponent(myState.computed.optionsWithIndex(state))
.forEach(e => ul.appendChild(e))
})
return ul
}
Used in section 12
An individual input element and remove button that allows editing and removal of an poll option.
function editOptionComponent(state, option) { const [index, value] = option const li = document.createElement('li') const input = inputComponent() const button = document.createElement('button') function removeOption(event) { event.preventDefault() state.transform(state => { state.options[index] = undefined return state }) } function updateOption(event) { state.transform(state => { state.options[index] = event.target.value return state }) } button.addEventListener('click', removeOption) button.textContent = 'x' input.value = value input.addEventListener('change', updateOption) li.appendChild(input) li.appendChild(button) return li }
Used in section 15
Allows the poll maker to create a new poll option.
function addOptionComponent(myState) { const form = document.createElement('form') const input = inputComponent() const button = document.createElement('button') function onAddOption(event) { event.preventDefault() myState.transform(state => { state.optionInput = '' state.options.push(input.value) return state }) return false } function onInput(event) { myState.transform(state => { state.optionInput = event.target.value return state }) } form.addEventListener('submit', onAddOption) input.addEventListener('input', onInput) myState.addListener(state => { input.value = state.optionInput myState.computed.canAddOption(state) ? button.removeAttribute('disabled') : button.setAttribute('disabled', 'disabled') }) form.appendChild(input) form.appendChild(button) button.textContent = 'add' return form }
Used in section 12
Where did mapComponents
come from? It is a helper that allows us to create
and update elements based on an array. There are also other helpers.
I have chosen to make this an higher order function for several reasons.
Firstly because I need some state to keep reference to the current elements. Ideally I want this state to be isolated to prevent undesired mutation. I can achieve that by using the function scope.
Secondly I can get more code reuse, allowing me to create multiple functions
that have similar behavior just by calling the mapComponents
helper.
The inner mapComponents function returns the newly created elements for the user to append to a parent element. For sake of ease I remove all old elements before creating all new elements. This removes the hassle of having to mutate existing DOM elements. This might be less memory efficient but it is much easier to write.
function mapComponents(createComponent) { let components = [] return function mapComponentsFn(array, ...rest) { const createdComponents = [] // Removal introduces an issue where currently selected input elements are // removed. As a result the cursor disappears. Bad UX. Should be mutating // existing elements to coincide with the latest state. Not doing for now. components.forEach(elem => elem.remove()) components = array .map((item, index) => createComponent(item, index, ...rest)) return components } }
Used in section 18
Why the compact helper? The alternative would be implementing a Linked List. Why? Imagine we have an array of options and I remove an option from the poll somewhere in the middle of the list. Everything after that removed item is moved one index down the array. All event listeners registered and other parts of the applications that uses the index of these options will have to be updated.
So what if we do not change the indices of the items ever. We can remove things
by just setting the value on a specific index to undefined
. We then use the
compact function to get all not yet removed items.
For sake of ease I chose to use a compact helper function instead of writing a linked list implementation. It's a time saver. The linked list could be implemented in the future. A linked list is the most elegant solution I can think of.
The compactWithIndex
version is necessary to enable correct mutation of the
original options array.
The first column was a lot to take in. We created helpers and quite some components. This column should be much more concise because we get to reuse those helpers and this column has less components. It is just a form with radio buttons.
function voteComponent(myState) {
const form = document.createElement('form')
const question = document.createElement('p')
const button = document.createElement('button')
const ul = document.createElement('ul')
let options = []
function onVote (event) {
event.preventDefault()
let option = options.find(li => li.children[0].checked)
if (option === undefined)
return false
const value = option.children[0].value
myState.transform(state => {
state.votes[value] = (state.votes[value] === undefined)
? 1
: state.votes[value] + 1
return state
})
return false
}
button.textContent = 'vote'
form.addEventListener('submit', onVote)
{Option Radio Component, 21}
const mapOptionRadioComponent = mapComponents(
optionRadioComponent.bind(null, myState)
)
myState.addListener(state => {
question.textContent = state.question
options = mapOptionRadioComponent(
myState.computed.optionsWithIndex(state))
options.forEach(elem => ul.appendChild(elem))
})
form.appendChild(question)
form.appendChild(ul)
form.appendChild(button)
return form
}
Used in section 10
function optionRadioComponent(state, option) { const li = document.createElement('li') const input = inputComponent() const label = document.createElement('label') label.textContent = option[1] label.setAttribute('for', option[0]) input.setAttribute('type', 'radio') input.setAttribute('id', option[0]) input.setAttribute('name', 'vote') input.setAttribute('value', option[0]) li.appendChild(input) li.appendChild(label) return li }
The third column reports on the votes. It is a bar chart where each bar displays the option and absolute amount of votes on every bar.
function resultComponent(myState) { const question = document.createElement('p') const div = document.createElement('div') {Bar Chart Component, 22} {Total Votes Component, 22} myState.addListener(state => question.textContent = state.question ) div.appendChild(question) div.appendChild(barChartComponent(myState)) div.appendChild(totalVotesComponent(myState)) return div }
Used in section 10
function barChartComponent(myState) {
const div = document.createElement('div')
{Bar Component, 22}
const mapBarComponent = mapComponents(barComponent)
myState.addListener(state => {
const maxVotes = myState.computed.maxVotes(state)
mapBarComponent(myState.computed.optionVotes(state), maxVotes)
.forEach(elem => div.appendChild(elem))
})
return div
}
function barComponent([_, votes, option], index, totalVotes) {
const div = document.createElement('div')
const label = document.createElement('div')
const value = document.createElement('span')
const percentage = (totalVotes
? votes / totalVotes
: 0) * 100
value.textContent = votes
value.style.position = 'absolute'
value.style.right = '0px'
label.textContent = option
{Colors Array, 5}
div.title = option
div.style.width = `${percentage}%`
div.style.height = '1.5rem'
div.style['background-color'] = colors[index]
div.style.border = '1px solid black'
div.style.position = 'relative'
div.style.bottom = '0px'
div.appendChild(value)
div.appendChild(label)
return div
}
function totalVotesComponent(myState) { const div = document.createElement('div') myState.addListener(state => { div.textContent = `Total votes: ${myState.computed.totalVotes(state)}` }) return div }
I needed some columns and better styling for the forms. Not interested in spending to much time on that.
<link rel="stylesheet" href="https://unpkg.com/purecss@2.0.3/build/pure-min.css" integrity="sha384-cg6SkqEOCV1NbJoCu11+bm0NvBRc8IYLRGXkmNrqUBfTjmMYwNKPWBTIKyw9mHNJ" crossorigin="anonymous"/> <link rel="stylesheet" href="https://unpkg.com/purecss@2.0.3/build/grids-responsive-min.css"/>
Used in section 2
We keep it simple and make the default HTML styling do most of the heavy lifting.
It is relatively simple to unit test these components as they are just functions. The document global variable should be mocked when running in a node environment, or one can run the tests in a browser environment (which should be better). Because of lack of time I won't be testing component functions.
With this project I have showcased my understanding of the fundamentals of a Single Page Application. This basic knowledge is essential regardless of the SPA framework that is being used.
The use of Literate Programming is something new for me. I wonder if you the reader has benefited from splitting the code into sections with explanation. I, as the programmer, enjoyed writing code like this. It created small and readable code sections that I could easily tie into the eventual code.
The choice to use Vanilla JS has been a interesting test for myself. It has
taken more time then I expected. SPA frameworks make templating a breeze,
compared to .appendChild
and .classList.add
. Most SPAs have conventions for
state management and the making of components that make it easier to work
together and across projects.