96.5% cov
2055 sloc
177 files
5 deps
18 dev deps
Great looking avatars for your agile board and experiment in FRAMEWORK-LESS, vanilla JavaScript.
- Background
- Getting Started
- Design Goals
- Technical Constraints
- Architecture
- Launching
- Composing
- Modules
- List of Modules
- State Management
- View Rendering
- Testing
- Dependencies
- List of Production Dependencies
- List of Development Dependencies
- Functional Programming
- Conventions
Agile Avatars makes it quick and easy to know who's working on what with great looking avatars for your agile board. No more fiddling with Word or Google Docs making sure everything aligns just right. Simply drag and drop your images, make some adjustments, print, and laminate!
Agile Avatars is also an experiment in developing a web application under an extreme set of constraints designed to preclude mainstream solutions. Bare in mind that Agile Avatars is small and doesn't necessarily cover every concern found in a typical web application. It does however do enough to present some interesting design challenges, especially around code organisation, dependency management, state management and view rendering.
The solutions are designed around the needs of this application at this point in time. The design is intended to be evolvable through refactoring as the needs of the application change over time. The intent is to see what kind of design emerges as a result of an extreme set of constraints.
- Run the tests:
./task test
- Start dev server:
./task serve
- List all tasks:
ls ./tasks
iTermocil allows you to setup pre-configured layouts of windows and panes in iTerm2.
- Install iTermocil and launch the pre-configured layout:
./task itermocil
- Beginner friendly. Minimise prerequisite knowledge.
- Approachable to developers of varying backgrounds and experience.
- Reduce cognitive load. Simplicity. Minimalism. Organisation. Ability to maintain a mental model.
- Minimise "infrastructure code". Not attempting duplicate mainstream design patterns or build a resuable framework.
- Low maintenance. Avoid dependencies that could impact the application in a material way.
- Flexibility. Avoid dependencies that take over the control flow of the application.
- Easy to change. Tests run fast. Tests are behavioural.
- Functional leaning. Avoid strict functional programming.
- Enables merciless refactoring.
- Embrace JavaScript as a dynamically typed language.
- No languages that compile to JavaScript, e.g. TypeScript.
- No frameworks, e.g. Angular, Vue.
- No view rendering libraries, e.g. React.
- No CSS-in-JS libraries, e.g. Styled Components.
- No CSS pre-processors, e.g. SASS, SCSS.
- No state management libraries, e.g. Flux, Redux.
- No functional programming libraries, e.g. Rambda, Immutable.
- No general purpose utility libraries, e.g. Lodash, Underscore.
- No task runners, e.g. Grunt, Gulp.
- No globals. Access to window strictly controlled.
- No classes/prototypes.
Further reading:
- List of languages that compile to JS
- The Brutal Lifecycle of JavaScript Frameworks - Ian Allen
- You Might Not Need TypeScript (or Static Types) - Eric Elliott
- The Shocking Secret About Static Types - Eric Elliott
- The TypeScript Tax - Eric Elliot
- Optimised for speed/fast feedback. Single digit seconds for entire suite.
- No compilation/pre-processing required to run tests.
- No globals. e.g. Mocha, Jest.
- No hooks. e.g. beforeEach, afterEach.
- No BDD-style assertion libraries, e.g. should or expect found in Mocha, Jest.
- No mocking libraries, e.g. Sinon, Jest.
- No circumvention of the module loading system, e.g. Rewire, Proxyquire, Jest.
- Mocks Aren't Stubs - Martin Fowler
- Classical and Mockist Testing
- Mockists Are Dead. Long Live Classicists - Fabio Pereria, ThoughtWorks
- TDD test suites should run in 10 seconds or less - Mark Seemann
- I strongly recommend that you skip all BDD style assertion libraries - Eric Elliott
- Mocking is a code smell - Eric Elliott
With the plethora of frontend architectural styles in use today, this application takes a back to basics approach with a classic layered architecture. The thought being that the simplicity and familiarity of this architectural style would be approachable for a wide audience including backend developers with limited exposure to frontend development.
Presentation-Domain-Data layered architecture
The application is built with a module bundler called Parcel. Given a HTML file, Parcel follows dependencies to produce a bundle. Parcel extends module loading to allow glob patterns and file types not normally recognised by JavaScript such as CSS files.
While convenient, this creates a strong coupling to Parcel, as in, the code cannot be interpreted without it. Pre-processing JavaScript, whether it be Parcel or any other tool, increases the time it takes to reflect changes. This is problematic in scenarios where speed matters, such as running unit tests.
The application is split into 2 top-level directories: public and src.
- public contains the entry HTML file, static assets such as images and CSS, and the minimum amount of code needed to launch 'the application'.
- src contains all the code comprising 'the application'.
In order to isolate Parcel, only public may use Parcel loaders. This allows unit tests to cover src without having to build the application which helps keep the tests fast.
The following code is referenced by index.html and launches the application:
import './css/*.css'; // eslint-disable-line import/no-unresolved
import compose from './compose';
const { startup } = compose({ window });
const app = startup.start();
document.getElementById('app').append(app);
- At build time, Parcel interprets
require('./css/*.css');
, combines each CSS file into a single file which is then referenced by a link tag that Parcel injects into the document head. - At run time, the compose function is invoked with the global window object and config, returning the initialised application modules.
- The modules are assigned to
window.app
for demonstration and debugging purposes. - The startup function is invoked with a callback receiving an instance of the root component, app, which is then appended to the document body.
Note: window.agileavatars
changed to window.app
.
Application modules logged to the console
Application state logged to the console
Composing is the process of making the application ready to launch and involves loading configuration, composing modules, and returning the composed modules.
The compose function composes the application from modules in the src directory.
import composer from 'module-composer';
import modules from './modules/index.js';
import defaultConfig from './default-config.js';
export default ({ window, config, overrides }) => {
const { compose } = composer(modules, { defaultConfig, config, overrides });
const { util } = compose.asis('util');
const { storage } = compose.asis('storage');
// Data
const { stores } = compose('stores', { storage });
const { subscriptions } = compose('subscriptions', { stores, util });
// Domain
const { core } = compose.deep('core', { util });
const { io } = compose('io', { window });
const { services } = compose.deep('services', { subscriptions, stores, core, io, util });
// Presentation
const { ui } = compose('ui', { window });
const { elements } = compose('elements', { ui, util });
const { vendorComponents } = compose('vendorComponents', { ui, window });
const { components } = compose.deep('components', { io, ui, elements, vendorComponents, services, subscriptions, util });
const { styles } = compose('styles', { ui, subscriptions });
// Startup
compose('diagnostics', { stores, util });
return compose('startup', { ui, components, styles, services, subscriptions, stores, util, window });
};
This codified view of the architecture has some interesting implications:
- Easier to understand how the application "hangs together".
- Easier to control and manage dependencies. Makes inappropriate dependencies more visible.
- Ability to test the integrated application without also launching it.
- Ability to programatically analyse and visualise dependencies.
Can't see the diagram? View it on GitHub
graph TD;
stores-->storage;
subscriptions-->stores;
io-->window;
services-->subscriptions;
services-->stores;
services-->core;
services-->io;
ui-->window;
elements-->ui;
components-->io;
components-->ui;
components-->elements;
components-->services;
components-->subscriptions;
styles-->ui;
styles-->subscriptions;
window is a global God object that makes it too easy to misplace responsibilities. For example, manipulating the DOM or making HTTP requests from anywhere in the application.
The application has been designed to mitigate such misplaced responsibilities by avoiding the global window object altogether. The compose function expects a window object to be explicitly provided which is then passed to only the selected modules that are allowed to access it.
While this helps be intentional of how window is accessed, it still doesn't prevent use of the global window object. So, in order to detect inappropriate access, window is not made globally available in the unit tests. This is possible because the unit tests run on Node.js instead of a browser environment. JSDOM is used to emulate a browser and create a non-global window object to provide to the compose function. This causes any code referencing the global window object to fail.
module-composer is a small library that reduces the amount of boilerplate code needed to compose modules. It was extracted from Agile Avatars for reuse.
https://github.com/mattriley/node-module-composer
The application is composed of architectural components called modules. Each module has a separate responsibility and may be composed with collaborating modules.
On the file system, a module is simply a directory of sources files that follow some simple rules:
- Each file and subdirectory (i.e. nested index.js) is loaded by index.js in the same directory.
- Each index.js exports an aggregate object of all files and directories loaded.
- Each file exports a function, so file names tend to be function names.
- Where a module is to be composed with collaborating modules, exported functions must be curried to first accept the collaborators.
import app from './app.js';
import dropzone from './dropzone.js';
import gravatar from './gravatar/index.js';
import header from './header/index.js';
import imageUploadOptions from './image-upload-options/index.js';
import modal from './modal.js';
import modals from './modals/index.js';
import optionsBar from './options-bar/index.js';
import roleList from './role-list/index.js';
import tagList from './tag-list/index.js';
import tips from './tips/index.js';
export default {
app,
dropzone,
gravatar,
header,
imageUploadOptions,
modal,
modals,
optionsBar,
roleList,
tagList,
tips
};
export default ({ elements, services, subscriptions }) => tagInstanceId => {
const $tagName = elements.editableSpan('tag-name')
.addEventListener('change', () => {
services.tags.changeTagName(tagInstanceId, $tagName.textContent);
});
subscriptions.tagInstances.onChange(tagInstanceId, 'tagName', tagName => {
$tagName.textContent = tagName;
});
return $tagName;
};
This design has some interesting implications:
- Any source file is only referenced and loaded once in the entire application making it easier to move files around.
- In general, index.js files don't have a clear responsibility, sometimes even containing important implementation details that can be hard to find given any Node.js project will have many of them. This design ensures index.js files have a clear responsibility of their own and don't contain important implementation details that would be better extracted and named appropriately.
- Remove the noise of many require/import statements at the top of any file.
- No backtracking paths, i.e.
..
helps reduce cognitive load (for me anyway!). - The approach to index.js forms a pattern which can be automated with code generation. See module-indexgen in the list of development dependencies.
Because all relative files are loaded by index.js files, a simple search can be done to identify any inappropriate file references. A task is run during pre-commit and fails if any inappropriate file references are found.
Following is a complete list of modules in Agile Avatars.
The diff-like block lists the collaborators in green and the non-collaborators in red.
Provides low-level utility functions.
+
- components core diagnostics elements io services startup storage stores styles subscriptions ui vendorComponents
debounce splitAt
mapValues upperFirst
pipe
Provides the state store implementation. State stores manage state changes and raise change events.
+
- components core diagnostics elements io services startup stores styles subscriptions ui util vendorComponents
storage is a single-file module:
import EventEmitter from 'events';
export default (defaults = {}) => {
let nextId = 1;
const state = new Map();
const funcs = new Map();
const collectionEmitter = new EventEmitter();
const manage = id => funcs.get(id) || { get: () => null };
const list = () => [...state.values()];
const find = id => manage(id).get();
const update = (id, changes) => manage(id).update(changes);
const onChange = (id, field, listener) => manage(id).subscriptions.onChange(field, listener);
const onChangeAny = (field, listener) => collectionEmitter.on(`change:${field}`, listener);
const onInsert = listener => collectionEmitter.on('insert', listener);
const onFirstInsert = listener => collectionEmitter.once('firstInsert', listener);
const onBeforeRemove = listener => collectionEmitter.on('beforeRemove', listener);
const subscriptions = { onChange, onChangeAny, onInsert, onFirstInsert, onBeforeRemove };
const insert = (data, callback) => {
const id = data.id || nextId++;
const item = { id, ...data };
const itemEmitter = new EventEmitter();
const get = () => ({ ...item });
const update = changes => {
Object.entries(changes).forEach(([field, val]) => {
if (item[field] === val) return;
item[field] = val;
const emit = emitter => emitter.emit(`change:${field}`, item[field], item);
[itemEmitter, collectionEmitter].forEach(emit);
});
};
const onChange = (field, listener) => {
itemEmitter.on(`change:${field}`, listener);
listener(item[field], item);
};
const subscriptions = { onChange };
funcs.set(id, { get, update, subscriptions });
state.set(id, item);
if (callback) callback(id);
collectionEmitter.emit('firstInsert', id);
collectionEmitter.emit('insert', id);
return id;
};
const remove = id => {
collectionEmitter.emit('beforeRemove', id);
funcs.delete(id);
state.delete(id);
};
Object.entries(defaults).map(([id, entry]) => ({ id, ...entry })).forEach(entry => insert(entry));
return { insert, remove, list, find, update, subscriptions };
};
Provides the state stores. State stores manage state changes and raise change events. State stores are created at compose time as defined in config.
+ storage
- components core diagnostics elements io services startup styles subscriptions ui util vendorComponents
stores
is a single-file module that creates stores dynamically from config:
export default ({ storage, config }) => () => {
return Object.fromEntries(config.storage.stores.map(name => {
const defaults = config.storage.defaults[name];
const store = storage.stateStore(defaults);
return [name, store];
}));
};
roles tagInstances
settings tags
Provides subscription functions. A subscription function enables a listener to be notified of state changes.
The subscription functions are actually implemented in the state store. This module exposes only the subscriptions from the stores to prevent direct read/write access to the the stores.
stores enable retrieval and updating of state, and the ability to subscribe to state change events. In our layered architecture, the domain layer depends on the data layer, and so the services module may access Stores directly.
The presentation layer however depends on the domain layer, and so the components module may not access Stores directly. That's to say, the presentation layer should not be retrieving and updating state directly.
The subscriptions module was introduced to allow Components to subscribe to state change events while preventing access to the underlying stores. The subscriptions module is generated from the Stores, only providing access to subscriptions.
+ stores util
- components core diagnostics elements io services startup storage styles ui vendorComponents
subscriptions is a single-file module that exposes only subscriptions from the stores:
export default ({ stores, util }) => () => {
return util.mapValues(stores, store => store.subscriptions);
};
Provides pure functions to be consumed by the services module. Without core, services would be interlaced with pure and impure functions, making them harder to test and reason about.
+ util
- components diagnostics elements io services startup storage stores styles subscriptions ui vendorComponents
- No access to modules that produce side effects.
parseEmailExpression is a pure function. Amongst other properties of pure functions, its return value is the same for the same arguments, and its evaluation has no side effects.
export default ({ util }) => expression => {
const indexOfAt = expression.indexOf('@');
const isEmail = indexOfAt > -1;
const [username] = (isEmail ? expression.substr(0, indexOfAt) : expression).split('+');
const lastIndexOfPlus = expression.lastIndexOf('+');
const hasRole = lastIndexOfPlus > indexOfAt;
const [emailOrUsername, roleName] = hasRole ? util.splitAt(expression, lastIndexOfPlus, 1) : [expression];
const email = isEmail ? emailOrUsername : '';
return { email, username, emailOrUsername, roleName };
};
/* FOOTNOTES
Example of complex expression: 'foo+bar@gmail.com+dev'
=> { email: 'foo+bar@gmail.com', username: 'foo', emailOrUsername: 'foo+bar@gmail.com', roleName: 'dev' }
*/
gravatar.buildImageUrl tags.parseEmailExpression
gravatar.buildProfileUrl tags.parseFileExpression
gravatar.getNameFromProfile tags.parseTagExpression
gravatar.hashEmail tags.planTagInstanceAdjustment
roles.assignColor tags.sortTagInstancesByTagThenMode
roles.buildRole tags.sortTagsByName
roles.randomColor tags.sortTagsByRoleThenName
tags.buildTag
Provides io functions while preventing direct access to window.
+
- components core diagnostics elements services startup storage stores styles subscriptions ui util vendorComponents
io is a single-file module:
import mixpanel from 'mixpanel-browser';
export default ({ window, config }) => () => {
config.mixpanelToken && mixpanel.init(config.mixpanelToken, { debug: config.isTest });
return {
mixpanel,
date: () => new window.Date(),
fetch: (...args) => window.fetch(...args),
random: () => window.Math.random(),
fileReader: () => new window.FileReader()
};
};
date mixpanel
fetch random
fileReader
Provides service functions. Service functions perform effects by orchestrate the pure functions from core, the impure functions from io (such as making HTTP requests), as well as updating state.
+ core io stores subscriptions util
- components diagnostics elements startup storage styles ui vendorComponents
- No access to window. IO operations are serviced by the io module.
export default ({ core, services, stores }) => (tagInstanceId, expression) => {
const { tagId } = services.tags.getTagInstance(tagInstanceId);
const { tagName, roleName } = core.tags.parseTagExpression(expression);
stores.tags.update(tagId, { tagName });
if (roleName) {
const roleId = services.roles.findOrInsertRoleWithName(roleName);
stores.tags.update(tagId, { roleId });
}
};
gravatar.changeFallback tags.adjustTagInstanceCounts
gravatar.changeFreetext tags.attachImageAsync
gravatar.fetchImageAsync tags.buildTagInstance
gravatar.fetchProfileAsync tags.changeTagName
gravatar.status tags.changeTagRole
roles.changeRoleColor tags.getTagInstance
roles.changeRoleName tags.insertFileAsync
roles.findOrInsertRoleWithName tags.insertFileBatchAsync
roles.getNilRoleId tags.insertGravatarAsync
roles.getRole tags.insertGravatarBatchAsync
roles.insertRole tags.insertTag
roles.isNilRole tags.insertTagInstance
roles.setupRolePropagation tags.removeTagInstance
settings.changeModal tags.setupRolePropagation
settings.changeOption tags.setupTagPropagation
settings.clearModal tags.sortTagInstances
settings.getGravatar
Provides low-level presentation functions while preventing direct access to window.
+
- components core diagnostics elements io services startup storage stores styles subscriptions util vendorComponents
appendToHead refocus
el toggleBoolClass
event
Provides element factory functions. An element is a HTML element that relies on closures to react to user interaction by updating the element or raising events for components. Unlike components, they cannot react to state changes or invoke services. Elements are lower level and may be reused by multiple components.
+ ui util
- components core diagnostics io services startup storage stores styles subscriptions vendorComponents
- No access to stores or io. Effects are serviced by raising events to be handled by components.
- No access to window. Low-level presentation concerns are serviced by the ui module.
export default ({ ui }) => className => {
const dispatchChange = () => $span.dispatchEvent(ui.event('change'));
const $span = ui.el('span', className)
.addEventListener('blur', () => {
dispatchChange();
})
.addEventListener('keydown', e => {
if (e.code === 'Enter') {
e.preventDefault();
dispatchChange();
}
});
$span.setAttribute('contenteditable', true);
return $span;
};
/* FOOTNOTES
- Content editable span preferred over text field for the ability to expand/contract while editing.
- `e.preventDefault()` on enter key prevents cursor moving to next line.
*/
dropzone layout
editableSpan modal
label number
Provides vendor (third party) components including gtag and vanilla-picker. These are separated from the components module because they have different collaborators. The components module avoids a direct dependency on window but some vendor components may require direct access to window which cannot be avoided.
+ ui
- components core diagnostics elements io services startup storage stores styles subscriptions util
vanillaPicker
Provides component factory functions. A component is a HTML element that relies on closures to react to user interaction and state changes by updating the DOM or invoking services for any non-presentation concerns.
+ elements io services subscriptions ui util vendorComponents
- core diagnostics startup storage stores styles
- No access to stores or io. Effects are serviced by the services module.
- No access to window. Low-level presentation concerns are serviced by the ui module.
tagName renders the tag name for a given tag instance. A tag is composed of an image, a name, and a role. Multiple instances of a tag may be rendered at a time depending on the numbers specified in the active and passive fields.
tagName accepts the ID of a tag instance and returns a content editable span. tagName reacts to changes by invoking the changeTagName service function with the new tag name.
changeTagName updates the state of the underlying tag, which triggers a propagation of the new tag name to all other instances of the tag.
tagName subscribes to tag name change events and updates the editable span with the new tag name.
export default ({ elements, services, subscriptions }) => tagInstanceId => {
const $tagName = elements.editableSpan('tag-name')
.addEventListener('change', () => {
services.tags.changeTagName(tagInstanceId, $tagName.textContent);
});
subscriptions.tagInstances.onChange(tagInstanceId, 'tagName', tagName => {
$tagName.textContent = tagName;
});
return $tagName;
};
app optionsBar.container
dropzone optionsBar.numberOption
gravatar.actions optionsBar.options
gravatar.content optionsBar.shapeOption
gravatar.title roleList.container
header.container roleList.roleCustomiser
header.titleBar tagList.container
imageUploadOptions.chooseImages tagList.tag
imageUploadOptions.container tips.badges
imageUploadOptions.gravatar tips.images
modal tips.laminating
modals.gravatar tips.multiples
modals.tips tips.naming
modals.welcome tips.roleShortcut
Provides style factory functions. A style is simply a HTML style element that relies on closures to react to state changes by updating the CSS content of the element. This enables dynamic styling. Styles are injected into the document head by styleManager which is loaded on startup.
+ subscriptions ui
- components core diagnostics elements io services startup storage stores util vendorComponents
export default ({ ui, subscriptions }) => roleId => {
const $style = ui.el('style');
subscriptions.roles.onChange(roleId, 'color', color => {
$style.textContent = `
.role${roleId} .tag-image { border-color: ${color}; }
.role${roleId} .role-name { background-color: ${color}; }
`;
});
return $style;
};
export default ({ styles, subscriptions, ui, util }) => () => {
const { tagImage, roleColor, ...otherStyles } = styles;
const appendStyles = (...$$styles) => ui.appendToHead(...$$styles);
appendStyles(...Object.values(otherStyles).map(style => style()));
subscriptions.tags.onInsert(util.pipe(tagImage, appendStyles));
subscriptions.roles.onInsert(util.pipe(roleColor, appendStyles));
};
roleColor tagSize
tagImage tagSpacing
tagOutline vanillaPicker
tagShape
Provides diagnostic functions such as the ability to dump state to the console.
+ stores util
- components core elements io services startup storage styles subscriptions ui vendorComponents
dumpState
Provides startup functions which are used at launch time.
+ components services stores styles subscriptions ui util
- core diagnostics elements io storage vendorComponents
- Largely unconstrained as only used during launch.
export default ({ startup, components }) => () => {
startup.insertNilRole();
startup.createHandlers();
startup.createStyleManager();
return components.app();
};
State management in an interesting problem to solve in this application due to the need for shared state between components.
Here's some examples:
Inserting a file (dropping, choosing, or Gravatar):
- Parses the file name to extract a tag name and optional role name.
- Renders a new master role badge in the roles list if it doesn't already exist.
- Renders multiple tag instances with the same image, name and role based on the values of the active and passive fields.
Changing the name of a role in the role list:
- Updates the role name of all tag instances referencing that role.
Changing the colour of a role in the role list:
- Updates the colour of the master role badge being updated.
- Updates the colour of the role and border colour of the image of all tag instances referencing that role.
Changing the role name of a tag instance:
- Renders a new role in the role list if it doesn't already exist.
- Updates the role name of all tag instances referencing that role.
- Updates the colour of the role and border colour of the image of all tag instances referencing that role.
Changing the name of a tag instance:
- Updates the name of all other instances of that tag.
Changing an option in the options bar including active, passive, shape, size, spacing and sort order affects all tags.
The constraint of no state management libraries forces the need for a bespoke state management solution. No attempt is made to generify the state management solution for reuse by other applications; rather it is designed to evolve with the specific needs of this application.
State is managed by a series of state stores.
A state store is collection of data items keyed by a unique identifier and managed using typical CRUD operations such as insert, find, update, remove.
import EventEmitter from 'events';
export default (defaults = {}) => {
let nextId = 1;
const state = new Map();
const funcs = new Map();
const collectionEmitter = new EventEmitter();
const manage = id => funcs.get(id) || { get: () => null };
const list = () => [...state.values()];
const find = id => manage(id).get();
const update = (id, changes) => manage(id).update(changes);
const onChange = (id, field, listener) => manage(id).subscriptions.onChange(field, listener);
const onChangeAny = (field, listener) => collectionEmitter.on(`change:${field}`, listener);
const onInsert = listener => collectionEmitter.on('insert', listener);
const onFirstInsert = listener => collectionEmitter.once('firstInsert', listener);
const onBeforeRemove = listener => collectionEmitter.on('beforeRemove', listener);
const subscriptions = { onChange, onChangeAny, onInsert, onFirstInsert, onBeforeRemove };
const insert = (data, callback) => {
const id = data.id || nextId++;
const item = { id, ...data };
const itemEmitter = new EventEmitter();
const get = () => ({ ...item });
const update = changes => {
Object.entries(changes).forEach(([field, val]) => {
if (item[field] === val) return;
item[field] = val;
const emit = emitter => emitter.emit(`change:${field}`, item[field], item);
[itemEmitter, collectionEmitter].forEach(emit);
});
};
const onChange = (field, listener) => {
itemEmitter.on(`change:${field}`, listener);
listener(item[field], item);
};
const subscriptions = { onChange };
funcs.set(id, { get, update, subscriptions });
state.set(id, item);
if (callback) callback(id);
collectionEmitter.emit('firstInsert', id);
collectionEmitter.emit('insert', id);
return id;
};
const remove = id => {
collectionEmitter.emit('beforeRemove', id);
funcs.delete(id);
state.delete(id);
};
Object.entries(defaults).map(([id, entry]) => ({ id, ...entry })).forEach(entry => insert(entry));
return { insert, remove, list, find, update, subscriptions };
};
export default ({ core, services, subscriptions, stores, io }) => roleData => {
const role = core.roles.buildRole(roleData, io.random());
return stores.roles.insert(role, roleId => {
subscriptions.roles.onChange(roleId, 'roleName', services.roles.setupRolePropagation(roleId));
});
};
export default ({ core, stores }) => (roleId, roleName) => {
const oldState = stores.roles.find(roleId);
const newState = core.roles.buildRole({ ...oldState, roleName });
stores.roles.update(roleId, newState);
};
State stores use the observer pattern to enable consumers to react to state changes by associating listener functions to events such as onInsert and onChange.
The observer pattern is easily implemented with Node's EventEmitter which can be bundled directly into the application.
During compose time, subscription functions are extracted from the stores to produce the subscriptions module. This decouples subscribers from the stores making them agnostic of the data source. Although not a design goal for this application, this should allow the data source to change without impacting the subscribers provided the interface of the subscription functions do not change.
export default ({ ui, components, subscriptions }) => () => {
const $roleList = ui.el('div', 'role-list visible-false');
subscriptions.roles.onInsert(roleId => {
const $role = components.roleList.roleCustomiser.container(roleId);
$roleList.append($role);
});
subscriptions.roles.onFirstInsert(() => {
ui.toggleBoolClass($roleList, 'visible', true);
});
return $roleList;
};
export default ({ elements, services, subscriptions }) => roleId => {
const $roleName = elements.editableSpan(`role-name role${roleId}`)
.addEventListener('change', () => {
services.roles.changeRoleName(roleId, $roleName.textContent);
});
subscriptions.roles.onChange(roleId, 'roleName', roleName => {
$roleName.textContent = roleName;
});
return $roleName;
};
View rendering is achieved primarily using the DOM API - document.createElement
, and by exception using HTML strings - element.innerHTML
.
Creating elements with the DOM API usually involves:
- Creating an element,
document.createElement('div')
- Assigning a class name,
element.className = 'myclass'
- Assigning properties,
element.prop1 = 'foo'
- Appending child elements,
element.append(child1, child2)
- Adding event listeners,
element.addEventListener('click', clickHandler)
This approach is sometimes criticised as verbose. While I only considered the verbosity a minor concern, I did notice a pattern emerge which lead me to the creation of a helper function, el
.
el
takes a tag name, an optional class name, and optional properties object. Because the native append
and addEventListener
functions return undefined, el
overrides them to return the element instead to enable function chaining.
const $div = el('div', 'myclass', { prop1: 'foo', prop2: 'bar' })
.append(child1, child2)
.addEventListener('focus', focusHandler)
.addEventListener('click', clickHandler);
The equivalent without el
:
const $div = document.createElement('div');
$div.className = 'myclass';
$div.prop1 = 'foo';
$div.prop2 = 'bar';
$div.append(child1, child2);
$div.addEventListener('focus', focusHandler);
$div.addEventListener('click', clickHandler);
export default ({ window }) => (tagName, ...opts) => {
const el = window.document.createElement(tagName);
const props = opts.map(opt => (typeof opt === 'string' ? { className: opt } : opt));
const funcs = ['append', 'addEventListener'].map(name => {
const orig = el[name].bind(el);
const func = (...args) => { orig(...args); return el; };
return { [name]: func };
});
return Object.assign(el, ...props, ...funcs);
};
Because ultimately this approach uses document.createElement
to create elements, and all interaction with elements are encapsulated within builder functions, we always have a direct reference to the element. This eliminates the need to assign an id, or lookup elements using document.getElementById
or document.querySelector
or some variation of these.
element.innerHTML
is used by exception, where HTML is used primarily for marking up blocks of content.
This example uses el
to create an element, but assigns a HTML string to innerHTML
rather than appending child elements.
export default ({ ui }) => () => {
return ui.el('div', {
title: 'Naming',
innerHTML: `
<p>
Prefer <mark>short names</mark> and <mark>abbreviated roles</mark>.
Less is more. Use just enough detail to identify people at a glance.
Avoid full names and position titles if possible.
</p>`
});
};
The application is tested from the outside-in, starting with the components. A component's behaviour is tested by the effect it has on other components, treating the low level details as a black box. These are "sociable" as opposed to "solitary" unit tests.
This test creates a 'nav bar' and a 'tips modal'; clicks the 'tips link' in the nav bar; then asserts the tips modal has a class indicating it should be visible. The mechanics behind this interaction are a black box, making it resilient to implementation changes which enables merciless refactoring.
export default ({ test, assert }, { helpers }) => ({ compose }) => {
test('tips modal triggered by link in nav bar', () => {
const { components } = compose().modules;
const $tipsLink = components.header.titleBar().querySelector('.tips');
const $tipsModal = components.modals.tips('tips');
const assertVisible = helpers.assertBoolClass(assert, $tipsModal, 'visible');
assertVisible(false);
helpers.dispatchEvent('click', $tipsLink);
assertVisible(true);
});
};
Not every component is tested directly. Many low level components can be treated as a black box when exercised by a higher level component.
Components are not designed and tested as though they'll be soon extracted as a reusable component library. This means components can be tested under the conditions they're used by this application, rather than how they might hypothetically be used by unknown consumers. This reduces the testing burden by allowing us to make reasonable assumptions about interactions between components, validity of parameters/data used, etc.
The intent with black box testing is to minimise the chances of tests breaking due to implmentation changes and thereby support merciless refactoring.
Exceptions are made to the black box approach under certain conditions:
- System boundary
- Narrow feedback
Where the execution path will reach a system boundary, stub just short of the integration to avoid coupling the test to the low level implementation details of the integration.
This test creates a 'gravatar modal' and a 'tag list'. Clicking the 'import button' will render a tag in the tag list using data fetched from Gravatar. The fetchProfileAsync and fetchImageAsync functions are stubbed to prevent the integration from occurring and to avoid coupling the test to the implementation details of the integration.
export default ({ test, assert }) => ({ setup }) => {
test('import success', async () => {
const { compose, helpers, window } = setup();
const { components } = compose({
overrides: {
services: {
gravatar: {
fetchProfileAsync: () => Promise.resolve({ displayName: 'foo' }),
fetchImageAsync: () => Promise.resolve(new window.Blob(['BYTES'], { type: 'image/jpg' }))
}
}
}
});
const $gravatarModal = components.modals.gravatar();
const $freetextField = $gravatarModal.querySelector('.freetext');
const $importButton = $gravatarModal.querySelector('.import');
const $tagList = components.tagList.container();
const assertGravatarModalVisible = helpers.assertBoolClass(assert, $gravatarModal, 'visible');
$freetextField.value = 'foo@bar.com';
helpers.dispatchEvent('input', $freetextField);
await helpers.onTagListMutation(
$tagList,
() => {
helpers.dispatchEvent('click', $importButton);
},
async tag1 => {
assert.equal(tag1.getTagName(), 'Foo');
assert.equal(await tag1.getImage(), 'url(data:image/jpg;base64,QllURVM=)');
assertGravatarModalVisible(false);
}
);
});
};
When it's helpful to narrow down failure feedback when execution path is too coarse. e.g. state-store evolved with the application rather than being built up-front. The state-store could be covered by the component tests but it's sufficiently complicated to justify it's own tests.
This testing approach supports classic TDD more so than mockist TDD.
- Mocks Aren't Stubs - Martin Fowler
- Classical vs Mockist testing - Jonathan Rasmusson
- Mockists Are Dead. Long Live Classicists - Fabio Pereria, ThoughtWorks
Links
Rather than acting on individual files, tests act on the initialised application.
This test initialises the application by invoking compose and uses the components module to create an 'options bar' which should initially be hidden. It then uses the services module to insert a tag which should cause the options bar to become visible.
export default ({ test, assert }, { helpers }) => ({ compose }) => {
test('options bar not visible until first tag inserted', () => {
const { components, services } = compose().modules;
const $optionsBar = components.optionsBar.container();
const assertVisible = helpers.assertBoolClass(assert, $optionsBar, 'visible');
assertVisible(false);
services.tags.insertTag();
assertVisible(true);
});
};
NB: As mentioned previously, compose has 1 required argument - window. This version of compose is actually a wrapper that supplies an instance of window provided by JSDOM to the original compose function for testing purposes.
The position taken in this application is to view depenendencies as liabilities. That's not to say dependencies should be avoided at all costs. The constraints below are designed to minimise dependencies and encourage due diligence in cases where dependencies might be appropriate.
Further reading:
- Unix philosophy - Wikipedia
- Dependency Management Guidelines For Rails Teams - Brandon Dees
- 3 pitfalls of relying on third-party code libraries - Andy Henson
- Not driven by hype or popularity
- No alternative built into JavaScript exists
- Non-trivial to implement with vanilla JavaScript
- No alternative built into Node.js exists
- No alternative that more closely matches the need exists
- No alternative with fewer dependencies exists
- Low learning curve
- Low maintenance
- Low likelihood of changing in a material way
- Low impact of material change
Production dependencies need to be carefully considered in order to keep the bundle size small. We can be more liberal with development dependencies as they don't impact the bundle size.
The following sections lists all dependencies, including:
- Description and Homepage taken from package.json.
- Number of production dependencies followed by:
- 💥 = 0 dependencies, ✅ = 1-9 dependencies,
⚠️ = 10+ dependencies - NB: There's no science behind these numbers. This is simply a guide to help keep the number of dependencies low.
- NB: It would be even better to list the total number of dependencies in the entire dependency tree.
- 💥 = 0 dependencies, ✅ = 1-9 dependencies,
- Description of what the dependency is used for.
- Clarifying comments against the constraints listed above.
JavaScript MD5 implementation. Compatible with server-side environments like Node.js, module loaders like RequireJS, Browserify or webpack and all web browsers.
- Homepage: https://github.com/blueimp/JavaScript-MD5
- 0 dependencies 💥
Hashing of email addresses for use with the Gravatar service.
-
No alternative built into JavaScript exists
JavaScript does not feature a built-in MD5 implementation. -
No alternative built into Node.js exists
The crypto module supports MD5. It does not seem possible to extract individual algorithms from crypto. The consequence is a minified bundle size of 431.78 KB compared with 4.86 KB for blueimp-md5 which is a significant difference. -
No alternative that more closely matches the need exists
According to this issue, the original use case was to hash email addresses for Gravatar.
undefined
- Homepage: undefined
- 0 dependencies 💥
undefined
- Homepage: undefined
- 0 dependencies 💥
Bring order to chaos. Level up your JS application architecture with Module Composer, a tiny but powerful module composition utility based on functional dependency injection.
- Homepage: https://github.com/mattriley/node-module-composer
- 1 dependency ✅
Module composition / dependency injection.
- No alternative that more closely matches the need exists
This library was extracted from Agile Avatars.
A simple, easy to use vanilla JS color picker with alpha selection.
- Homepage: https://vanilla-picker.js.org
- 1 dependency ✅
Presenting a color picker to change the color of a role.
undefined
- Homepage: undefined
- 0 dependencies 💥
undefined
- Homepage: undefined
- 0 dependencies 💥
undefined
- Homepage: undefined
- 0 dependencies 💥
undefined
- Homepage: undefined
- 0 dependencies 💥
An AST-based pattern checker for JavaScript.
- Homepage: https://eslint.org
- 37 dependencies
⚠️
Linting and code formatting.
- prettier
Prettier was originally used for code formatting but was dropped due to limited configurability.
undefined
- Homepage: undefined
- 0 dependencies 💥
undefined
- Homepage: undefined
- 0 dependencies 💥
undefined
- Homepage: undefined
- 0 dependencies 💥
Modern native Git hooks made easy
- Homepage: https://typicode.github.io/husky
- 0 dependencies 💥
Running pre-commit validation scripts.
A JavaScript implementation of many web standards
- Homepage: undefined
- 23 dependencies
⚠️
Emulating a web browser so tests can be run with Node.js for speed.
- Low impact of material change
There does not seem to be any viable replacement for JSDOM. The fallback would be to run the tests in a browser. The cost is estimated to be low.
Generates barrel (index.js) files that rollup exports for each module in a directory and re-exports them as a single module.
- Homepage: https://github.com/mattriley/node-module-indexgen
- 4 dependencies ✅
Generating index.js files.
- No alternative that more closely matches the need exists
This library was extracted from Agile Avatars.
undefined
- Homepage: undefined
- 0 dependencies 💥
undefined
- Homepage: undefined
- 0 dependencies 💥
undefined
- Homepage: undefined
- 0 dependencies 💥
undefined
- Homepage: undefined
- 0 dependencies 💥
undefined
- Homepage: undefined
- 0 dependencies 💥
undefined
- Homepage: undefined
- 0 dependencies 💥
undefined
- Homepage: undefined
- 0 dependencies 💥
Although strict functional design is not a design goal, there are certain functional principles which are easily applied in vanilla JavaScript and should be within grasp of the average developer.
Care should be taken to avoid mutation but this is not strictly enforced.
Mutation should be intentional and well controlled.
Avoid introducing libraries that enforce immutability. While it's tempting to introduce a library like Immutable.js to enforce immutability, adding a library also adds another level of complexity and cognative load to the developer experience. Sometimes such libraries are used as "guardrails" to enforce immutability in teams where there are concerns around code quality, but at the same time, this can limit the developer's ability to make mistakes and learn to truly understand and value immutability.
As a rule of thumb, prefer const
over let
, and avoid var
.
While this will not guarantee immutability, it will challenge people to think about it. If let
is seen as a smell, it may drive refactoring toward const
which will likely result in a better design. An example would be recognising the let
in a for
loop as a smell, triggering a refactor toward a higher-order function.
Prefer higher-order functions such as filter
, map
, reduce
, over imperative looping statements.
This function transforms a list of store names into an object of store name -> store. This could also be done with a for
loop. Reduce hides the low level implementation details of iteration. It also removes the need for intermedite variables such as loop counters.
The acc
variable is intentionally mutated given the scope of the mutation is small and isolated within the reduce function. An immutable equivalent could be { ...acc, [name]: store }
.
export default ({ storage, config }) => () => {
return Object.fromEntries(config.storage.stores.map(name => {
const defaults = config.storage.defaults[name];
const store = storage.stateStore(defaults);
return [name, store];
}));
};
As much as possible, pure functions are separated from impure functions. To make the distinction clear, pure domain functions are kept in the core
module. Pure functions can be reasoned about and tested in isolation without having to manage side effects.
From Wikipedia:
In computer programming, a pure function is a function that has the following properties:
- Its return value is the same for the same arguments (no variation with local static variables, non-local variables, mutable reference arguments or input streams from I/O devices).
- Its evaluation has no side effects (no mutation of local static variables, non-local variables, mutable reference arguments or I/O streams).
This function orchestrates pure and impure functions making it impure. However because the implementation of parseFileExpression
has been extracted as a pure function.
export default ({ core, services, util }) => file => {
return util.pipe(
core.tags.parseFileExpression,
services.tags.insertTag,
services.tags.attachImageAsync(file)
)(file.name);
};
export default () => expression => {
const [tagName, roleName] = expression
.split('/')
.pop()
.match(/^(\d+)?(.+)/)[2]
.split('.')[0]
.split('+')
.map(s => s.trim());
return { tagName, roleName };
};
/* FOOTNOTES
Example of complex expression: '1 foo bar+dev.jpg'
=> { tagName: 'foo bar', roleName: 'dev' }
Leading numbers are stripped to enable inserting tags in a preferred order.
*/
Where possible, use pipe
to avoid nesting function calls and intermediate variables.
export default ({ core, services, util }) => file => {
return util.pipe(
core.tags.parseFileExpression,
services.tags.insertTag,
services.tags.attachImageAsync(file)
)(file.name);
};
export default (...funcs) => initial => funcs.reduce((v, f) => f(v), initial);
Once the pipeline operator is officially supported in JavaScript, we can remove the custom implementation.
I generally prefer to avoid variable prefixes but I've found these prefixes help in a couple of ways:
- Improves visual scanning of code making it faster to interpret.
- Avoids naming conflicts, e.g.
$tagName.textContext = tagName;
Such comments are secondary to the code and so follow the code rather than preceed it.
export default ({ ui }) => () => {
return ui.el('div', 'tag-image');
};
/* FOOTNOTES
Actual image is rendered using CSS background-image as a performance optimisation.
*/
This just makes it easier to know when to use await
.
- Table of contents limited to heading 1.
- Headings for "lists" should begin with List of.
- Wherever possible render actual source files for example code.