├── .eslintrc.json ├── .gitignore ├── .husky └── pre-commit ├── .node-version ├── .nvmrc ├── .parcelrc ├── README-TEMPLATE.md ├── README.md ├── docs ├── CNAME ├── img │ ├── gravatar-fallbacks │ │ ├── identicon.png │ │ ├── index.js │ │ ├── monsterid.png │ │ ├── mp.png │ │ ├── retro.png │ │ ├── robohash.png │ │ └── wavatar.png │ ├── tips │ │ ├── active-passive.png │ │ └── blocked-risk.png │ └── welcome.png ├── index.bc4c23cb.js ├── index.bc4c23cb.js.map ├── index.c5ef59e1.css ├── index.c5ef59e1.css.map └── index.html ├── package-lock.json ├── package.json ├── readme-files ├── architecture-compact.svg ├── architecture-subs-ext.svg ├── architecture.svg ├── console-modules.png ├── console-state.png ├── demo.gif ├── itermocil.png ├── modules-pres.svg └── modules.svg ├── readme-gen ├── app.js ├── assets │ ├── dependencies │ │ ├── constraints.yaml │ │ └── dependencies.yaml │ ├── diagrams │ │ ├── architecture-compact.drawio │ │ ├── architecture-subs-ext.drawio │ │ ├── architecture.drawio │ │ ├── modules-pres.mmd │ │ └── modules.mmd │ ├── modules │ │ ├── components.md │ │ ├── config.md │ │ ├── core.md │ │ ├── diagnostics.md │ │ ├── elements.md │ │ ├── io.md │ │ ├── services.md │ │ ├── startup.md │ │ ├── storage.md │ │ ├── stores.md │ │ ├── styles.md │ │ ├── subscriptions.md │ │ ├── ui.md │ │ ├── util.md │ │ ├── vendorComponents.md │ │ └── vendorServices.md │ ├── sections │ │ ├── architecture.md │ │ ├── badges.md │ │ ├── composing.md │ │ ├── conventions.md │ │ ├── dependencies.md │ │ ├── design-goals.md │ │ ├── functional-programming.md │ │ ├── getting-started.md │ │ ├── launching.md │ │ ├── modules.md │ │ ├── state-management.md │ │ ├── technical-constraints.md │ │ ├── testing.md │ │ └── view-rendering.md │ └── static │ │ ├── console-modules.png │ │ ├── console-state.png │ │ ├── demo.gif │ │ └── itermocil.png ├── compose.js ├── modules │ ├── index.js │ └── renderers │ │ ├── index.js │ │ ├── render-collaborators.js │ │ ├── render-dependencies.js │ │ ├── render-dependency-constraints.js │ │ ├── render-dependency.js │ │ ├── render-index.js │ │ └── render-modules.js └── package.json ├── src ├── app.js ├── compose.js ├── css │ ├── app.css │ ├── control-panel.css │ ├── gravatar-spinner.css │ ├── gravatar.css │ ├── header.css │ ├── image-upload-options.css │ ├── modal.css │ ├── nil-role.css │ ├── options.css │ ├── roles.css │ ├── site.css │ ├── tags.css │ └── tips.css ├── default-config.js ├── index.html ├── index.template.html └── modules │ ├── components │ ├── app.js │ ├── dropzone.js │ ├── gravatar │ │ ├── actions │ │ │ ├── container.js │ │ │ ├── error.js │ │ │ ├── import-button.js │ │ │ ├── index.js │ │ │ └── loading.js │ │ ├── content │ │ │ ├── container.js │ │ │ ├── fallback.js │ │ │ ├── fallbacks.js │ │ │ ├── freetext.js │ │ │ └── index.js │ │ ├── index.js │ │ └── title.js │ ├── header │ │ ├── container.js │ │ ├── index.js │ │ └── title-bar.js │ ├── image-upload-options │ │ ├── choose-images.js │ │ ├── container.js │ │ ├── gravatar.js │ │ └── index.js │ ├── index.js │ ├── modal.js │ ├── modals │ │ ├── gravatar.js │ │ ├── index.js │ │ ├── tips.js │ │ └── welcome.js │ ├── options-bar │ │ ├── container.js │ │ ├── index.js │ │ ├── number-option.js │ │ ├── options │ │ │ ├── index.js │ │ │ ├── modes.js │ │ │ ├── outline.js │ │ │ ├── shapes.js │ │ │ ├── size.js │ │ │ ├── sort.js │ │ │ └── spacing.js │ │ └── shape-option.js │ ├── role-list │ │ ├── container.js │ │ ├── index.js │ │ └── role-customiser │ │ │ ├── container.js │ │ │ ├── index.js │ │ │ ├── master-role-name.js │ │ │ └── role-color-picker.js │ ├── tag-list │ │ ├── container.js │ │ ├── index.js │ │ └── tag │ │ │ ├── components │ │ │ ├── index.js │ │ │ ├── role-name.js │ │ │ ├── tag-image.js │ │ │ └── tag-name.js │ │ │ ├── container.js │ │ │ └── index.js │ └── tips │ │ ├── badges.js │ │ ├── images.js │ │ ├── index.js │ │ ├── laminating.js │ │ ├── multiples.js │ │ ├── naming.js │ │ └── role-shortcut.js │ ├── core │ ├── gravatar │ │ ├── build-image-url.js │ │ ├── build-profile-url.js │ │ ├── get-name-from-profile.js │ │ ├── hash-email.js │ │ └── index.js │ ├── index.js │ ├── roles │ │ ├── assign-color.js │ │ ├── build-role.js │ │ ├── index.js │ │ └── random-color.js │ └── tags │ │ ├── build-tag.js │ │ ├── index.js │ │ ├── parse-email-expression.js │ │ ├── parse-file-expression.js │ │ ├── parse-tag-expression.js │ │ ├── plan-tag-instance-adjustment.js │ │ ├── sort-tag-instances-by-tag-then-mode.js │ │ ├── sort-tags-by-name.js │ │ └── sort-tags-by-role-then-name.js │ ├── diagnostics │ ├── dump-state.js │ └── index.js │ ├── elements │ ├── dropzone.js │ ├── editable-span.js │ ├── index.js │ ├── label.js │ ├── layout.js │ ├── modal.js │ └── number.js │ ├── index.js │ ├── io │ ├── index.js │ └── setup.js │ ├── services │ ├── gravatar │ │ ├── change-fallback.js │ │ ├── change-freetext.js │ │ ├── fetch-image-async.js │ │ ├── fetch-profile-async.js │ │ ├── index.js │ │ └── status.js │ ├── index.js │ ├── roles │ │ ├── change-role-color.js │ │ ├── change-role-name.js │ │ ├── find-or-insert-role-with-name.js │ │ ├── get-nil-role-id.js │ │ ├── get-role.js │ │ ├── index.js │ │ ├── insert-role.js │ │ ├── is-nil-role.js │ │ └── setup-role-propagation.js │ ├── settings │ │ ├── change-modal.js │ │ ├── change-option.js │ │ ├── clear-modal.js │ │ ├── get-gravatar.js │ │ └── index.js │ └── tags │ │ ├── adjust-tag-instance-counts.js │ │ ├── attach-image-async.js │ │ ├── build-tag-instance.js │ │ ├── change-tag-name.js │ │ ├── change-tag-role.js │ │ ├── get-tag-instance.js │ │ ├── index.js │ │ ├── insert-file-async.js │ │ ├── insert-file-batch-async.js │ │ ├── insert-gravatar-async.js │ │ ├── insert-gravatar-batch-async.js │ │ ├── insert-tag-instance.js │ │ ├── insert-tag.js │ │ ├── remove-tag-instance.js │ │ ├── setup-role-propagation.js │ │ ├── setup-tag-propagation.js │ │ └── sort-tag-instances.js │ ├── startup │ ├── create-handlers.js │ ├── create-style-manager.js │ ├── index.js │ ├── insert-nil-role.js │ └── start.js │ ├── storage │ ├── index.js │ └── state-store.js │ ├── stores │ ├── index.js │ └── setup.js │ ├── styles │ ├── index.js │ ├── role-color.js │ ├── tag-image.js │ ├── tag-outline.js │ ├── tag-shape.js │ ├── tag-size.js │ ├── tag-spacing.js │ └── vanilla-picker.js │ ├── subscriptions │ ├── index.js │ └── setup.js │ ├── ui │ ├── append-to-head.js │ ├── el.js │ ├── event.js │ ├── index.js │ ├── refocus.js │ └── toggle-bool-class.js │ ├── util │ ├── debounce.js │ ├── index.js │ ├── map-values.js │ ├── pipe.js │ ├── split-at.js │ └── upper-first.js │ └── vendor-components │ ├── index.js │ └── vanilla-picker.js ├── static └── img │ ├── gravatar-fallbacks │ ├── identicon.png │ ├── index.js │ ├── monsterid.png │ ├── mp.png │ ├── retro.png │ ├── robohash.png │ └── wavatar.png │ ├── tips │ ├── active-passive.png │ └── blocked-risk.png │ └── welcome.png ├── task-vars ├── testing ├── compose.js ├── modules │ ├── helpers │ │ ├── assert-bool-class.js │ │ ├── dispatch-drop-files.js │ │ ├── dispatch-event.js │ │ ├── dispatch-keydown.js │ │ ├── get-role.js │ │ ├── get-roles.js │ │ ├── get-tag-image-async.js │ │ ├── get-tags.js │ │ ├── index.js │ │ ├── on-mutation.js │ │ ├── on-tag-list-mutation.js │ │ └── test.js │ └── index.js ├── test-config.js └── test-runner.mjs └── tests ├── components ├── app.test.js ├── choose-images.test.js ├── drop-images.test.js ├── gravatar.test.js ├── gravatar │ ├── import-failure.fixme.js │ └── import-success.fixme.js ├── mode.test.js ├── options-bar.test.js ├── outline.test.js ├── role-color.test.js ├── role-name.test.js ├── shape.test.js ├── size.test.js ├── sort.test.js ├── spacing.test.js ├── tag-name.test.js ├── tips.test.js └── welcome.test.js ├── core ├── gravatar │ ├── build-image-url.test.js │ ├── build-profile-url.test.js │ └── get-name-from-profile.test.js ├── roles │ └── build-role.test.js └── tags │ ├── parse-email-expression.test.js │ └── parse-file-expression.test.js ├── diagnostics └── dump-state.test.js ├── elements ├── editable-span.test.js ├── modal.test.js └── number.test.js ├── services └── gravatar │ ├── fetch-image-async.test.js │ └── fetch-profile-async.test.js ├── storage └── state-store.test.js └── util └── debounce.test.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "commonjs": true, 6 | "es2021": true 7 | }, 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:import/recommended" 11 | ], 12 | "parserOptions": { 13 | "ecmaVersion": "latest", 14 | "allowImportExportEverywhere": true, 15 | "sourceType": "module" 16 | }, 17 | "rules": { 18 | "indent": [ 19 | "error", 20 | 4 21 | ], 22 | "linebreak-style": [ 23 | "error", 24 | "unix" 25 | ], 26 | "quotes": [ 27 | "error", 28 | "single" 29 | ], 30 | "semi": [ 31 | "error", 32 | "always" 33 | ], 34 | "comma-dangle": [ 35 | "error", 36 | "never" 37 | ], 38 | "arrow-parens": [ 39 | "error", 40 | "as-needed" 41 | ], 42 | "sort-vars": [ 43 | "off" 44 | ], 45 | "no-empty-pattern": [ 46 | "off" 47 | ] 48 | }, 49 | "overrides": [], 50 | "plugins": [] 51 | } 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /*cache 3 | /*output 4 | /.nyc_output 5 | /.serverless 6 | /.tap 7 | /.vscode 8 | /build 9 | /config.* 10 | /coverage 11 | /dist 12 | /dist* 13 | /itermocil.yml 14 | /metrics 15 | /node_modules 16 | /node_modules* 17 | /npm-debug.log 18 | /task-library 19 | /temp 20 | node_modules 21 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx task pre-commit 5 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 20.2.0 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.2.0 2 | -------------------------------------------------------------------------------- /.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@parcel/config-default", 3 | "resolvers": [ 4 | "@parcel/resolver-glob", 5 | "..." 6 | ], 7 | "transformers": { 8 | "*.{js,mjs,jsx,cjs,ts,tsx}": [ 9 | "@parcel/transformer-js", 10 | "@parcel/transformer-react-refresh-wrap" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /README-TEMPLATE.md: -------------------------------------------------------------------------------- 1 | <%- lib.renderOpening() %> 2 | 3 | # Background 4 | 5 | > 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! 6 | 7 | <%- await lib.renderImage('readme-files/demo.gif', 'Agile Avatars in action') %> 8 | 9 | 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. 10 | 11 | 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. 12 | 13 | # Getting Started 14 | 15 | <%- await include('./readme-gen/assets/sections/getting-started.md') %> 16 | 17 | # Design Goals 18 | 19 | <%- await include('./readme-gen/assets/sections/design-goals.md') %> 20 | 21 | # Technical Constraints 22 | 23 | <%- await include('./readme-gen/assets/sections/technical-constraints.md') %> 24 | 25 | # Architecture 26 | 27 | <%- await include('./readme-gen/assets/sections/architecture.md') %> 28 | 29 | # Launching 30 | 31 | <%- await include('./readme-gen/assets/sections/launching.md') %> 32 | 33 | # Composing 34 | 35 | <%- await include('./readme-gen/assets/sections/composing.md') %> 36 | 37 | # Modules 38 | 39 | <%- await include('./readme-gen/assets/sections/modules.md') %> 40 | 41 | # List of Modules 42 | 43 | Following is a complete list of modules in Agile Avatars. 44 | 45 | The diff-like block lists the collaborators in green and the non-collaborators in red. 46 | 47 | <%- await lib.renderModules() %> 48 | 49 | # State Management 50 | 51 | <%- await include('./readme-gen/assets/sections/state-management.md') %> 52 | 53 | # View Rendering 54 | 55 | <%- await include('./readme-gen/assets/sections/view-rendering.md') %> 56 | 57 | # Testing 58 | 59 | <%- await include('./readme-gen/assets/sections/testing.md') %> 60 | 61 | # Dependencies 62 | 63 | <%- await include('./readme-gen/assets/sections/dependencies.md') %> 64 | 65 | # List of Production Dependencies 66 | 67 | <%- await lib.renderDependencies('dependencies') %> 68 | 69 | # List of Development Dependencies 70 | 71 | <%- await lib.renderDependencies('devDependencies') %> 72 | 73 | # Functional Programming 74 | 75 | <%- await include('./readme-gen/assets/sections/functional-programming.md') %> 76 | 77 | # Conventions 78 | 79 | <%- await include('./readme-gen/assets/sections/conventions.md') %> 80 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | agileavatars.com 2 | -------------------------------------------------------------------------------- /docs/img/gravatar-fallbacks/identicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/docs/img/gravatar-fallbacks/identicon.png -------------------------------------------------------------------------------- /docs/img/gravatar-fallbacks/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | __modulename: 'gravatarFallbacks', 3 | identicon: require('./identicon'), 4 | monsterid: require('./monsterid'), 5 | mp: require('./mp'), 6 | retro: require('./retro'), 7 | robohash: require('./robohash'), 8 | wavatar: require('./wavatar') 9 | }; 10 | -------------------------------------------------------------------------------- /docs/img/gravatar-fallbacks/monsterid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/docs/img/gravatar-fallbacks/monsterid.png -------------------------------------------------------------------------------- /docs/img/gravatar-fallbacks/mp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/docs/img/gravatar-fallbacks/mp.png -------------------------------------------------------------------------------- /docs/img/gravatar-fallbacks/retro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/docs/img/gravatar-fallbacks/retro.png -------------------------------------------------------------------------------- /docs/img/gravatar-fallbacks/robohash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/docs/img/gravatar-fallbacks/robohash.png -------------------------------------------------------------------------------- /docs/img/gravatar-fallbacks/wavatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/docs/img/gravatar-fallbacks/wavatar.png -------------------------------------------------------------------------------- /docs/img/tips/active-passive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/docs/img/tips/active-passive.png -------------------------------------------------------------------------------- /docs/img/tips/blocked-risk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/docs/img/tips/blocked-risk.png -------------------------------------------------------------------------------- /docs/img/welcome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/docs/img/welcome.png -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | Agile Avatars
-------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "agile-avatars", 3 | "version": "1.0.0", 4 | "description": "Great looking avatars for your agile board and experiment in FRAMEWORK-LESS, vanilla JavaScript.", 5 | "license": "GPL-3.0-or-later", 6 | "homepage": "https://github.com/mattriley/agile-avatars", 7 | "repository": "github:mattriley/agile-avatars", 8 | "author": { 9 | "name": "Matt Riley", 10 | "email": "m@ttriley.dev", 11 | "url": "https://github.com/mattriley" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/mattriley/agile-avatars/issues", 15 | "email": "m@ttriley.dev" 16 | }, 17 | "type": "module", 18 | "private": true, 19 | "scripts": { 20 | "cov": "npx task cov", 21 | "deploy": "npx task deploy", 22 | "lint": "npx task lint", 23 | "pre": "npx task pre", 24 | "setup": "npm i && npx task setup", 25 | "start": "npx task start", 26 | "test": "npx task test" 27 | }, 28 | "dependencies": { 29 | "blueimp-md5": "^2.19.0", 30 | "lodash": "^4.17.21", 31 | "mixpanel-browser": "^2.47.0", 32 | "module-composer": "^0.163.0", 33 | "vanilla-picker": "github:mattriley/vanilla-picker" 34 | }, 35 | "devDependencies": { 36 | "@parcel/resolver-glob": "^2.9.3", 37 | "cloc": "^2.11.0", 38 | "doctoc": "^2.2.1", 39 | "ejs": "^3.1.9", 40 | "eslint": "^8.50.0", 41 | "eslint-plugin-import": "^2.28.1", 42 | "events": "^3.3.0", 43 | "flat": "^6.0.1", 44 | "husky": "^8.0.3", 45 | "jsdom": "^22.1.0", 46 | "module-indexgen": "^0.41.0", 47 | "module-testrunner": "^0.21.0", 48 | "npm-check-updates": "^16.14.4", 49 | "parcel": "^2.9.3", 50 | "process": "^0.11.10", 51 | "task-library": "^0.274.0", 52 | "testing": "file:./testing", 53 | "yaml": "^2.3.2" 54 | }, 55 | "browserslist": [ 56 | ">1%", 57 | "not ie 11", 58 | "not op_mini all" 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /readme-files/console-modules.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/readme-files/console-modules.png -------------------------------------------------------------------------------- /readme-files/console-state.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/readme-files/console-state.png -------------------------------------------------------------------------------- /readme-files/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/readme-files/demo.gif -------------------------------------------------------------------------------- /readme-files/itermocil.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/readme-files/itermocil.png -------------------------------------------------------------------------------- /readme-gen/app.js: -------------------------------------------------------------------------------- 1 | const compose = require('./compose'); 2 | const _readmeGenLib = require('task-library/src/node/lib/readme-gen'); 3 | const path = require('path'); 4 | const YAML = require('yaml'); 5 | const fs = require('fs'); 6 | const _ = require('lodash'); 7 | const glob = require('fast-glob'); 8 | const ejs = require('ejs'); 9 | 10 | const fsp = fs.promises; 11 | 12 | const start = async () => { 13 | 14 | const readmeGenLib = _readmeGenLib(); 15 | 16 | const constraintsFile = await fsp.readFile('./readme-gen/assets/dependencies/constraints.yaml', 'utf8'); 17 | const dependencyConstraints = YAML.parse(constraintsFile); 18 | 19 | const loadDependencies = async () => { 20 | const dependenciesFile = await fsp.readFile('./readme-gen/assets/dependencies/dependencies.yaml', 'utf8'); 21 | const dependencies = YAML.parse(dependenciesFile); 22 | const packages = _.mapValues(dependencies, (val, name) => { 23 | try { 24 | return require(path.resolve(`./node_modules/${name}/package.json`)); 25 | } 26 | catch (err) { 27 | // some don't have package.json? 28 | return {}; 29 | } 30 | }); 31 | 32 | return { dependencies, packages }; 33 | }; 34 | 35 | const dependencies = await loadDependencies(); 36 | 37 | const loadTemplates = async pattern => { 38 | const templateFiles = await glob(pattern); 39 | return templateFiles.reduce((acc, f) => { 40 | const template = fs.readFileSync(f, 'utf-8'); 41 | const { name } = path.parse(f); 42 | return Object.assign(acc, { [name]: template }); 43 | }, {}); 44 | }; 45 | 46 | const moduleTemplates = await loadTemplates('./readme-gen/assets/modules/*.md'); 47 | 48 | 49 | const target = { 50 | package: require(path.resolve('./package.json')), 51 | composition: await readmeGenLib.compose(c => c.composition), 52 | dependencies, 53 | dependencyConstraints, 54 | moduleTemplates 55 | }; 56 | 57 | const io = { fs, glob, ejs }; 58 | const { renderers } = compose({ readmeGenLib, io, target }); 59 | // await readmeGenLib.renderFile(renderers); 60 | return readmeGenLib.renderFile(renderers); 61 | }; 62 | 63 | start(); 64 | -------------------------------------------------------------------------------- /readme-gen/assets/dependencies/constraints.yaml: -------------------------------------------------------------------------------- 1 | noHype: Not driven by hype or popularity 2 | noNative: No alternative built into JavaScript exists 3 | noVanilla: Non-trivial to implement with vanilla JavaScript 4 | noNode: No alternative built into Node.js exists 5 | noCloser: No alternative that more closely matches the need exists 6 | noDeps: No alternative with fewer dependencies exists 7 | lowLearning: Low learning curve 8 | lowMaintenance: Low maintenance 9 | lowChange: Low likelihood of changing in a material way 10 | lowImpact: Low impact of material change 11 | -------------------------------------------------------------------------------- /readme-gen/assets/dependencies/dependencies.yaml: -------------------------------------------------------------------------------- 1 | '@sentry/browser': 2 | usedFor: Integration with [Sentry](https://sentry.io/) for monitoring and alerting. 3 | 4 | blueimp-md5: 5 | usedFor: Hashing of email addresses for use with the Gravatar service. 6 | comments: 7 | noNative: JavaScript does not feature a built-in MD5 implementation. 8 | noNode: 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. 9 | noCloser: According to [this issue](https://github.com/blueimp/JavaScript-MD5/issues/26), the original use case was to hash email addresses for Gravatar. 10 | 11 | module-composer: 12 | usedFor: Module composition / dependency injection. 13 | comments: 14 | noCloser: This library was extracted from Agile Avatars. 15 | 16 | vanilla-picker: 17 | usedFor: Presenting a color picker to change the color of a role. 18 | 19 | c8: 20 | usedFor: Code coverage 21 | alternativesConsidered: 22 | nyc: nyc was originally used for code coverage and was fine however c8 was chosen for leveraging [native coverage](https://nodejs.org/dist/latest-v10.x/docs/api/cli.html#cli_node_v8_coverage_dir) in recent versions of Node and V8 23 | 24 | chokidar-cli: 25 | usedFor: Running tests automatically on file change. 26 | 27 | eslint: 28 | usedFor: Linting and code formatting. 29 | alternativesConsidered: 30 | prettier: Prettier was originally used for code formatting but was dropped due to limited configurability. 31 | 32 | husky: 33 | usedFor: Running pre-commit validation scripts. 34 | 35 | jsdom: 36 | usedFor: Emulating a web browser so tests can be run with Node.js for speed. 37 | comments: 38 | lowImpact: 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. 39 | 40 | module-indexgen: 41 | usedFor: Generating index.js files. 42 | comments: 43 | noCloser: This library was extracted from Agile Avatars. 44 | 45 | parcel-bundler: 46 | usedFor: Bundling the application. 47 | comments: 48 | noDeps: Parcel has many dependencies. An exception is made for ease of use. 49 | lowLearning: Designed to be easier to use than webpack. 50 | -------------------------------------------------------------------------------- /readme-gen/assets/diagrams/architecture-compact.drawio: -------------------------------------------------------------------------------- 1 | 5Vtbc5s6EP41fnQHcbHxY2wn7aRJp6fOtMl5OaNgGdRg5Ar51l9fCMJcJBOHGEROXzxokYS0336r1UruGZPl7iOFK++WzJHf07X5rmdMe7o+MgfRbyzYJ4KBrSUCl+J5IgKZYIZ/Iy5Mq63xHIWFiowQn+FVUeiQIEAOK8ggpWRbrLYgfvGrK+giQTBzoC9Kf+A58xKprQ8z+SeEXS/9MhiMkjdLmFbmMwk9OCfbnMi47BkTSghLnpa7CfJj3aV6SdpdHXl7GBhFATulwQpvnX/s2w2Y3V8GwZNjwd2kn85jA/01nzEfLdunKqBkHcxR3IvWM8ZbDzM0W0EnfruNMI9kHlv6UQlEjwvs+xPiE/rc1lgAazSKmo7F0fIJbBBlaJcT8dF/RGSJGN1HVfhb3eSa5KZkpOVtBoypcZmXAyU1OchtwT10nakreuAae4X2jIa1ZxuPI1tvRnvmQLX29EGz2tO08XRy0Yz2DqxuQ3vY3YJfT99u3G/fL/6D32cTbfi5bwq6QvPIc/EiocwjLgmgf5lJx0VtZnVuCFlxHf5EjO25G4ZrRooaRjvM7nnz+Pkhfv5g8dJ0l3s13aeFIJrufb6QaxUXs2bPpbRdMr94UkcdHheFZE0dVGFpFl84IHURq6gHbLkRUORDhjfFgcgQfW56QSnc5yqsCA5YmOv5ayzI2ZZe8mt2yZG/UB9oWsmakhFktnWYSn2yWgJXQ0Q32EHh2zh7Bm4eeJfqT+LZbAk1zaYc20ApNQ90fMi9eYmaGRsfCmSsomYdJg5PZKIlZf8bmHkqoD8+3f68+rIYjO6sX/R6++Vqe+f1R90BVKsFKFAOaOqz2ke0atg5d+aQ5YoEiHvqTjk0XRLoNuXQpPYPgKCTzhOgTrBRhwD2+ySALRAA+WjZCfM/2LYC85dv84CgLBV8aN5J250y0TQFlI85GaEdjDhNW7GFdslBdy3kPHnz1y0HbagNOuts8EGvpTUXmJ3HlI2nn+27p4Hva3u8p8vp7PrfvtEdllaHUXMYes/fBEXKDlvaVZwM8PDc+NbK4AzLGYhyKr5U/5B7ldcvZnDE1sMjefB0uInWeKuSyb4uFSTfDYi5ILSRRo4xLDfwEflFi4Q+doPo2YkaIRoJ4pUXO9C/4C+WeD5PjB+F+Dd8fO4vtigORNS5Ne5ZU6mNVTFPWOIPB0f8I7382Yxs6e9rH0y9oH7ez+ssTAA10uqo0G3fNop9kMUiRG8FVJpKFjfDqvE8GadToa/CU7eBXQzntDNBWnILfb0tRBWHg8MaG/ZXBg+FFUq6CJxv5ZGaVVuBhBRf8eTsnTL2uPUeZWxE2JEJioQ1zkPYtJ99qdw8YRVHhnqtFPProsE2GXt8I6iKsZJ82/+IsS+usaZlnImjQ7sBTlYaVj7/tH4MHYpXDJNAfRpqMFCXhmIT98YFgXsV7MbX7goH3p6m8Y0aLwZ6NVIWrR0TAMk5gVSHQOoaleVgRb+FiXLDL58QqLb7d5PXaf98OF1C3pnd6+LZwxort/vyuQNQfdNFF5dIh1CkXFFW+UqVakUZoqK+RoFaNEMYxxKCwqJ5sqJWQkbJE0qvNwYkiJ1I/sYjF6WBoo8WrCpMlIFQhGlBApb6sOPx+Rvu72oiKCMJKHpjoIjb6ClZQvx3wmFKrrS2C4cYa0whg38lGJbkzkO7YIgLoIogp37gceqxb7qEdeSqYXpi+Y5iySaOfY+nXDt91apq2IVrLBFu6vMH5SipzYtWVXuAnKa2OIj/NKVaU+XAu7kNZ1TM/hKWZLKy/9UZl38A -------------------------------------------------------------------------------- /readme-gen/assets/diagrams/architecture-subs-ext.drawio: -------------------------------------------------------------------------------- 1 | 5Vtbd5s4EP41fnQPIIPhMTa5nGySdjc5m2TfFJBBDUaukG/99SuMMGBh6ktskfbFBw0SSN/MNzMa5A4YjhfXFE7Ce+KjqGNo/qID3I5hOD2L/6aCZSawbC0TBBT7mUgvBI/4JxLCvNsU+yipdGSERAxPqkKPxDHyWEUGKSXzarcRiapvncAASYJHD0ay9Bn7LMykttEv5DcIB2H+Zt1ysjtjmHcWK0lC6JN5SQQuO2BICWHZ1XgxRFGKXY5LNu5qy931xCiK2S4DJnju/W3fz/THl8s4fvdMuBh2gVjHDEZTsWIxW7bMIaBkGvsofYrWAYN5iBl6nEAvvTvnOueykI0j3tL55QhH0ZBEhK7GAhu8OTZf6kCerVjADFGGFiWRmP01ImPE6JJ3EXd7AkhhSWYO7LzQy1oWlnWSC6GwhWD96AIufiEQ2wM948TojXTTcfyToAcs9ehZp0VP0wbu8OIk6BnaOdF7vrn/fvUwspwn8we9nT9czZ/Crg4ksJDPXZdoEspCEpAYRpeFdFCFs+hzR8hEgPgdMbYUfhhOGalCjBaYvYjh6fVrev3FFC13UbrlLvNGzNf7Um6URqXNYtiqlY/bqraETKmHGizLFIEC0gCxhn66nXVMgWu0AooiyPCsGhPqVLoaekEpXJY6TAiOWVJ68rdUUBiXUTWu3qYjb+5uaRu2lL2+sKz1Og6nqikxNUF0hj2UHMfYD2DmJjV7NdTU66jZO5Vfs5QSc03G19KdXxGz4OJrhYofTcz+jsQ0P5qXR7lapz0K1Q5SqK5coTkBz6/RpmmX/JlHxhMSI+GnW+XR1u1zeLT6XEOXQGk9A86Va9ifkwG2xAAUoXEr7N9WaP71m2RdAksFH07vpT88HT4K9rwEVM46GaEtzDnNms30eU20TR66bUnnzrvBdnlooDbtPGSDr3fOFHT1Xut1uujiZ/e6qz1B5+uD6968/nvxT1cxS42DthL9vfYSPkzC1WwLX7sB/olVbqpUsFzSRLPapCrF5A6+oaiqKxjhIObXHh+EKBekQQl7MLoQN8bY9zOzQAn+Cd9Wz0txF0Um/nBz0DHdNfhSVFt/KxGDO+uCUlkt2813a1TsclPRnUpgBOLJ+9XPiopX3oWMRglX+qZ29qts1S5JSSZ35N7+kxGyfyQhD6qpWlY1RQO95qIqAI39jy6r1hqfXFb9nbyF8StvYdhGBXOr9d5C7TcXQfz93MWeOdk5vQUbuH/ZT+9WFGlLvKRj9/H2v20x5jzhu/dbExI0ElL7YpmOVSFkHh1bQchGsyrXCKZviUfxhGESqy8VWJrCUgEbBneBHgdX8WJwG0xwHC6p2oxH7xywrTxbLVevKebWYqjXekZlhTK5mIuJcsvfKOOes4pbqzPFsbvNX/FyPXwyuzfkAvEUK7f7zeLw2sqVFYcNOUh6hCLlSPW0TRehGikgI/WNZ2p8iTBNJyTE+EJZFZaEUfKO8lNoMYlTN1I+mCZEeaYYoRFryhPrtFDV04jELPdi2xP03bVibnx+02WlODU6OdkxQSDX1FwyhviP1Aboq9aGnGu4kME/URcmUK0LOf6pyHEOzzt2/TKXB7CWnAfLPeInSiVP8WVue/mm1cdhmqZdOWrA9aa+frCZI6nfR8lQzXGc/rNFNVTySeCTIcWbxR93slpW8e8ncPk/ -------------------------------------------------------------------------------- /readme-gen/assets/diagrams/architecture.drawio: -------------------------------------------------------------------------------- 1 | 5Vtbc5s6EP41fnQGmYvNY2wn7eQknZ5xZpr0TQEZ1ADiCPnWX3/AiKsI8Q2LTl8yaFnJ0re7n1YrMlBn/vYLhaH7RGzkDUaKvR2o88FoZGpG/DcR7FKBMVFSgUOxnYpAIVjg34gLM7UVtlFUUWSEeAyHVaFFggBZrCKDlJJNVW1JvOqvhtBBgmBhQU+U/sA2c1PpZDQu5F8Rdtzsl4Fhpm98mCnzlUQutMmmJFLvBuqMEsLSJ387Q16CXYZL2u/+g7f5xCgK2CEdQryx/p08rcHi5S4I3i0dbmdDla9jDb0VXzGfLdtlEFCyCmyUjKIM1OnGxQwtQmglbzexzWOZy3wvboH4cYk9b0Y8Qvd91Yn6Zk7ipU7F2fIFrBFlaFsS8dl/QcRHjO5iFf52pHAkuSvpGbKbwjC5zC0bJRNC7gxOPnaBV/zAITsCvlHH8C2Bbpp2N/Cphnz4jG7hU5TpfHbbDXx5+yrw/fj69Ov+29Iwn/X/6MPm2/3m2R0CVUAL2TF78SahzCUOCaB3V0inVTwLnUdCQo7iL8TYjlMxXDFSxRhtMXvh3ZPn1+T5Ruet+bb0ar7LGkG83pdyo9QraRbd9q2s34d2i8iKWqjFtXS+V0DqINaiByapYgJcqxtQ5EGG19Vtocmk+663lMJdSSEkOGBRaeTviaDwLjCpepdWJ/NP9A2l5k3pBArfyldyerTqQrBGiK6xhaLzgvYCwanVglNrCE7QFJxaV9RmSA3NPBxfS28+C80iGl8rwXjp0BwfGJr6pSPzLLI1+2NQ5SSDAukGzQLw+hZtm3aJzyzihyRAnKl7xWg5U12D0ZqzDSCA0vsIuFa2MfkzI2AiRADykN8L/8/9WIb/Nx+VgYCWjIDonqYvnhGfBXtWCCqnnYzQHiadesOB+rou2ieK7lvWefCBsF8UrcrNO08544PBlXZdoP2ZNtV6dDg8+CwxPuowYcPI3U/2vC3rYAv366yYTbucWK0b06pkwo/wDXlVW0EPO0H8bMWdEI0Fya6ELejd8hc+tu3ULVCEf8O3/XgJ8LzQFA+uTwf6vBH9VqcU9r/8boX/yqB8fdG0Lyo3ijqq7IxDPtBxNbSi6JWpkOUyit2gbp7jaltsOv9n8vxueJ6ywzvqzxcPP4dSK6mgHI83o0NLqT0PyfGlQ/Kkwuq4do7Qx+2F1bq+qrXra2ar/tmV2GZ+EUuxfeKXtgg7n16Gyo1WpZcz2SUbFwCzxlrAqA7SHf9o/eGfcTdpXof004go6Af9GDX6MT+hn5r+pS92mulEvIbtPZ1kGdYFshVd6XG20hoE5ZrI6i2yKA4ZJoH80ohed/trlkbYzHl0QODcB9vpgxPiwN3RoZRaXTPBdnlVXqO7Ezm2oZzdCKrcwqBYvcZEuufX69bXLFs32kjuNyJXu7e8iONnhum344/EivgKS3f8ejU893Jp1fCRuEtahCLpSNU3R/kfq6giUt/jTC1eIkzyCQGxeKGsCkvEKHlH2ad3AQkSGil/jcdFWabooSVryxObrFC105IELGOxj0P+COIGtcwbiFYxG4zS2ceRqpiVz4kP8d9pDnUs2xxiujGHDP6VxtBV2cYQt0AZac7pmcaht5HZHtaXiw1FBsxnZZNd3Ea2FZx6/A1Q27Qrn1fEdutfDUH+UUqEaoOD5H96LgBVQyFLQO/g3LtDpOJm8S9LaT2r+L8v9e5/ -------------------------------------------------------------------------------- /readme-gen/assets/diagrams/modules-pres.mmd: -------------------------------------------------------------------------------- 1 | graph TD; 2 | components-->elements; 3 | components-->services; 4 | services-->subscriptions; 5 | components-->subscriptions; 6 | -------------------------------------------------------------------------------- /readme-gen/assets/diagrams/modules.mmd: -------------------------------------------------------------------------------- 1 | graph TD; 2 | services-->core; 3 | components-->elements; 4 | services-->io; 5 | components-->services; 6 | subscriptions-->stores; 7 | services-->stores; 8 | services-->subscriptions; 9 | components-->subscriptions; 10 | styles-->subscriptions; 11 | elements-->ui; 12 | components-->ui; 13 | styles-->ui; 14 | io-->window; 15 | ui-->window; 16 | -------------------------------------------------------------------------------- /readme-gen/assets/modules/components.md: -------------------------------------------------------------------------------- 1 | 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. 2 | 3 | #### Collaborators 4 | 5 | <%- await lib.renderCollaborators() %> 6 | 7 | - No access to _stores_ or _io_. Effects are serviced by the _services_ module. 8 | - No access to _window_. Low-level presentation concerns are serviced by the _ui_ module. 9 | 10 | #### Example: tagName 11 | 12 | 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. 13 | 14 | tagName accepts the ID of a tag instance and returns a [content editable](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content) span. tagName reacts to changes by invoking the changeTagName service function with the new tag name. 15 | 16 | changeTagName updates the state of the underlying tag, which triggers a propagation of the new tag name to all other instances of the tag. 17 | 18 | tagName subscribes to tag name change events and updates the editable span with the new tag name. 19 | 20 | <%- await lib.renderCode(lib.fetchCode('src/modules/components/tag-list/tag/components/tag-name.js')) %> 21 | 22 | #### List of components 23 | 24 | <%- await lib.renderIndex() %> 25 | -------------------------------------------------------------------------------- /readme-gen/assets/modules/config.md: -------------------------------------------------------------------------------- 1 | Provides _static application config_ as a plain JavaScript object, including default state used to initialise the state stores. Config is loaded at [compose](#composing) time. 2 | 3 | #### Collaborators 4 | 5 | <%- await lib.renderCollaborators() %> 6 | 7 | - No collaborators required. 8 | 9 | #### Source 10 | 11 | _config_ is a single-file module: 12 | 13 | <%- await lib.renderCode(lib.fetchCode('src/config/config.js')) %> 14 | -------------------------------------------------------------------------------- /readme-gen/assets/modules/core.md: -------------------------------------------------------------------------------- 1 | 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. 2 | 3 | #### Collaborators 4 | 5 | <%- await lib.renderCollaborators() %> 6 | 7 | - No access to modules that produce side effects. 8 | 9 | #### Example: parseEmailExpression 10 | 11 | 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. 12 | 13 | <%- await lib.renderCode(lib.fetchCode('src/modules/core/tags/parse-email-expression.js')) %> 14 | 15 | #### List of core functions 16 | 17 | <%- await lib.renderIndex() %> 18 | 19 | #### Further reading 20 | 21 | - [Pure function - Wikipedia](https://en.wikipedia.org/wiki/Pure_function) 22 | - [Functional Core, Imperative Shell - Gary Bernhardt](https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell) 23 | -------------------------------------------------------------------------------- /readme-gen/assets/modules/diagnostics.md: -------------------------------------------------------------------------------- 1 | Provides _diagnostic functions_ such as the ability to dump state to the console. 2 | 3 | #### Collaborators 4 | 5 | <%- await lib.renderCollaborators() %> 6 | 7 | #### List of diagnostic functions 8 | 9 | <%- await lib.renderIndex() %> 10 | -------------------------------------------------------------------------------- /readme-gen/assets/modules/elements.md: -------------------------------------------------------------------------------- 1 | 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. 2 | 3 | #### Collaborators 4 | 5 | <%- await lib.renderCollaborators() %> 6 | 7 | - No access to _stores_ or _io_. Effects are serviced by raising events to be handled by _components_. 8 | - No access to _window_. Low-level presentation concerns are serviced by the _ui_ module. 9 | 10 | #### Example: editableSpan 11 | 12 | <%- await lib.renderCode(lib.fetchCode('src/modules/elements/editable-span.js')) %> 13 | 14 | #### List of elements 15 | 16 | <%- await lib.renderIndex() %> 17 | -------------------------------------------------------------------------------- /readme-gen/assets/modules/io.md: -------------------------------------------------------------------------------- 1 | Provides _io functions_ while preventing direct access to _window_. 2 | 3 | #### Collaborators 4 | 5 | <%- await lib.renderCollaborators() %> 6 | 7 | #### Source 8 | 9 | _io_ is a single-file module: 10 | 11 | <%- await lib.renderCode(lib.fetchCode('src/modules/io/setup.js')) %> 12 | 13 | #### List of io functions 14 | 15 | <%- await lib.renderIndex({ maxDepth: 1 }) %> 16 | -------------------------------------------------------------------------------- /readme-gen/assets/modules/services.md: -------------------------------------------------------------------------------- 1 | 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. 2 | 3 | #### Collaborators 4 | 5 | <%- await lib.renderCollaborators() %> 6 | 7 | - No access to _window_. IO operations are serviced by the _io_ module. 8 | 9 | #### Example: changeTagName 10 | 11 | <%- await lib.renderCode(lib.fetchCode('src/modules/services/tags/change-tag-name.js')) %> 12 | 13 | #### List of service functions 14 | 15 | <%- await lib.renderIndex() %> 16 | -------------------------------------------------------------------------------- /readme-gen/assets/modules/startup.md: -------------------------------------------------------------------------------- 1 | Provides _startup functions_ which are used at [launch](#launching) time. 2 | 3 | #### Collaborators 4 | 5 | <%- await lib.renderCollaborators() %> 6 | 7 | - Largely unconstrained as only used during launch. 8 | 9 | #### Example: start 10 | 11 | <%- await lib.renderCode(lib.fetchCode('src/modules/startup/start.js')) %> 12 | -------------------------------------------------------------------------------- /readme-gen/assets/modules/storage.md: -------------------------------------------------------------------------------- 1 | Provides the _state store implementation_. State stores manage state changes and raise change events. 2 | 3 | #### Collaborators 4 | 5 | <%- await lib.renderCollaborators() %> 6 | 7 | #### Source 8 | 9 | _storage_ is a single-file module: 10 | 11 | <%- await lib.renderCode(lib.fetchCode('src/modules/storage/state-store.js')) %> 12 | -------------------------------------------------------------------------------- /readme-gen/assets/modules/stores.md: -------------------------------------------------------------------------------- 1 | Provides the _state stores_. State stores manage state changes and raise change events. State stores are created at [compose](#composing) time as defined in config. 2 | 3 | #### Collaborators 4 | 5 | <%- await lib.renderCollaborators() %> 6 | 7 | #### Source 8 | 9 | `stores` is a single-file module that creates stores dynamically from _config_: 10 | 11 | <%- await lib.renderCode(lib.fetchCode('src/modules/stores/setup.js')) %> 12 | 13 | #### List of stores 14 | 15 | <%- await lib.renderIndex({ maxDepth: 1 }) %> 16 | -------------------------------------------------------------------------------- /readme-gen/assets/modules/styles.md: -------------------------------------------------------------------------------- 1 | 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. 2 | 3 | #### Collaborators 4 | 5 | <%- await lib.renderCollaborators() %> 6 | 7 | #### Example: roleColor 8 | 9 | <%- await lib.renderCode(lib.fetchCode('src/modules/styles/role-color.js')) %> 10 | 11 | #### Source: styleManager 12 | 13 | <%- await lib.renderCode(lib.fetchCode('src/modules/startup/create-style-manager.js')) %> 14 | 15 | #### List of styles 16 | 17 | <%- await lib.renderIndex() %> 18 | -------------------------------------------------------------------------------- /readme-gen/assets/modules/subscriptions.md: -------------------------------------------------------------------------------- 1 | Provides _subscription functions_. A subscription function enables a listener to be notified of state changes. 2 | 3 | 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. 4 | 5 | _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. 6 | 7 | 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. 8 | 9 | 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. 10 | 11 | #### Collaborators 12 | 13 | <%- await lib.renderCollaborators() %> 14 | 15 | #### Source 16 | 17 | _subscriptions_ is a single-file module that exposes only subscriptions from the stores: 18 | 19 | <%- await lib.renderCode(lib.fetchCode('src/modules/subscriptions/setup.js')) %> 20 | -------------------------------------------------------------------------------- /readme-gen/assets/modules/ui.md: -------------------------------------------------------------------------------- 1 | Provides _low-level presentation functions_ while preventing direct access to window. 2 | 3 | #### Collaborators 4 | 5 | <%- await lib.renderCollaborators() %> 6 | 7 | #### List of ui functions 8 | 9 | <%- await lib.renderIndex() %> 10 | -------------------------------------------------------------------------------- /readme-gen/assets/modules/util.md: -------------------------------------------------------------------------------- 1 | Provides _low-level utility functions_. 2 | 3 | #### Collaborators 4 | 5 | <%- await lib.renderCollaborators() %> 6 | 7 | #### List of utility functions 8 | 9 | <%- await lib.renderIndex() %> 10 | -------------------------------------------------------------------------------- /readme-gen/assets/modules/vendorComponents.md: -------------------------------------------------------------------------------- 1 | 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. 2 | 3 | #### Collaborators 4 | 5 | <%- await lib.renderCollaborators() %> 6 | 7 | #### List of vendor components 8 | 9 | <%- await lib.renderIndex() %> 10 | -------------------------------------------------------------------------------- /readme-gen/assets/modules/vendorServices.md: -------------------------------------------------------------------------------- 1 | Provides vendor (third party) services including gtag and sentry. These are separated from the services module because they have different collaborators. The services module avoids a direct dependency on window but some vendor services may require direct access to window which cannot be avoided. 2 | 3 | #### Collaborators 4 | 5 | <%- await lib.renderCollaborators() %> 6 | 7 | #### Example: gtag 8 | 9 | gtag is short for Google Global Site Tag. 10 | 11 | gtag depends on window for global variables to work correctly. 12 | 13 | <%- await lib.renderCode(lib.fetchCode('src/modules/vendor-services/gtag.js')) %> 14 | 15 | #### List of vendor services 16 | 17 | <%- await lib.renderIndex({ maxDepth: 1 }) %> 18 | -------------------------------------------------------------------------------- /readme-gen/assets/sections/architecture.md: -------------------------------------------------------------------------------- 1 | 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. 2 | 3 | <%- await lib.renderImage('readme-files/architecture.svg', 'Presentation-Domain-Data layered architecture') %> 4 | 5 | #### Further reading 6 | 7 | - [PresentationDomainDataLayering - Martin Fowler](https://martinfowler.com/bliki/PresentationDomainDataLayering.html) 8 | -------------------------------------------------------------------------------- /readme-gen/assets/sections/badges.md: -------------------------------------------------------------------------------- 1 | ![Build](https://github.com/mattriley/agileavatars/workflows/Build/badge.svg) 2 | [![codecov](https://codecov.io/gh/mattriley/agileavatars/branch/master/graph/badge.svg)](https://codecov.io/gh/mattriley/agileavatars) 3 | ![Status](https://img.shields.io/uptimerobot/status/m783034155-295e5fbc9fd4a0e3a54363a5) 4 | ![30 days](https://img.shields.io/uptimerobot/ratio/m783034155-295e5fbc9fd4a0e3a54363a5) 5 | -------------------------------------------------------------------------------- /readme-gen/assets/sections/composing.md: -------------------------------------------------------------------------------- 1 | Composing is the process of making the application _ready to launch_ and involves loading configuration, composing modules, and returning the composed modules. 2 | 3 | The compose function composes the application from modules in the src directory. 4 | 5 | <%- await lib.renderCode(lib.fetchCode('src/compose.js')) %> 6 | 7 | This _codified view_ of the architecture has some interesting implications: 8 | 9 | - Easier to understand how the application "hangs together". 10 | - Easier to control and manage dependencies. Makes inappropriate dependencies more visible. 11 | - Ability to test the integrated application without also launching it. 12 | - Ability to programatically analyse and visualise dependencies. 13 | 14 | <%- await lib.mermaid({ omit: ['config', 'diagnostics', 'startup', 'util', 'vendorComponents', 'vendorServices'] }) %> 15 | 16 | ## Deglobalising window 17 | 18 | _window_ is a global [God object](https://en.wikipedia.org/wiki/God_object) that makes it too easy to misplace responsibilities. For example, manipulating the DOM or making HTTP requests from anywhere in the application. 19 | 20 | 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. 21 | 22 | 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. 23 | 24 | ## module-composer 25 | 26 | _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. 27 | 28 | https://github.com/mattriley/node-module-composer 29 | 30 | ##### Further reading 31 | 32 | - [Composition Root - Mark Seemann](https://blog.ploeh.dk/2011/07/28/CompositionRoot/) 33 | -------------------------------------------------------------------------------- /readme-gen/assets/sections/conventions.md: -------------------------------------------------------------------------------- 1 | ## Code 2 | 3 | ### Prefix $ to variables storing HTML element and $$ for collections of HTML elements 4 | 5 | I generally prefer to avoid variable prefixes but I've found these prefixes help in a couple of ways: 6 | 7 | 1. Improves visual scanning of code making it faster to interpret. 8 | 2. Avoids naming conflicts, e.g. `$tagName.textContext = tagName;` 9 | 10 | ### Clarifying comments as footnotes 11 | 12 | Such comments are secondary to the code and so follow the code rather than preceed it. 13 | 14 | <%- await lib.renderCode(lib.fetchCode('src/modules/components/tag-list/tag/components/tag-image.js')) %> 15 | 16 | ### Async functions end with the word Async 17 | 18 | This just makes it easier to know when to use `await`. 19 | 20 | 21 | ## Documentation 22 | 23 | - Table of contents limited to heading 1. 24 | - Headings for "lists" should begin with __List of__. 25 | - Wherever possible render actual source files for example code. 26 | -------------------------------------------------------------------------------- /readme-gen/assets/sections/dependencies.md: -------------------------------------------------------------------------------- 1 | ## Position 2 | 3 | The position taken in this application is to view depenendencies as liabilities. 4 | That's not to say dependencies should be avoided at all costs. 5 | The constraints below are designed to minimise dependencies and encourage due diligence in cases where dependencies might be appropriate. 6 | 7 | Further reading: 8 | - [Unix philosophy - Wikipedia](https://en.wikipedia.org/wiki/Unix_philosophy) 9 | - [Dependency Management Guidelines For Rails Teams - Brandon Dees](https://blog.engineyard.com/dependency-management-guidelines-for-rails-teams) 10 | - [3 pitfalls of relying on third-party code libraries - Andy Henson](https://www.foxsoft.co.uk/3-pitfalls-of-relying-on-third-party-code-libraries/) 11 | 12 | ## Constraints 13 | 14 | <%- await lib.renderDependencyConstraints() %> 15 | 16 | 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. 17 | 18 | The following sections lists all dependencies, including: 19 | 20 | - Description and Homepage taken from package.json. 21 | - Number of production dependencies followed by: 22 | - :boom: = 0 dependencies, :white_check_mark: = 1-9 dependencies, :warning: = 10+ dependencies 23 | - NB: There's no science behind these numbers. This is simply a guide to help keep the number of dependencies low. 24 | - NB: It would be even better to list the total number of dependencies in the entire dependency tree. 25 | - Description of what the dependency is used for. 26 | - Clarifying comments against the constraints listed above. 27 | 28 | -------------------------------------------------------------------------------- /readme-gen/assets/sections/design-goals.md: -------------------------------------------------------------------------------- 1 | - Beginner friendly. Minimise prerequisite knowledge. 2 | - Approachable to developers of varying backgrounds and experience. 3 | - Reduce cognitive load. Simplicity. Minimalism. Organisation. Ability to maintain a mental model. 4 | - Minimise "infrastructure code". Not attempting duplicate mainstream design patterns or build a resuable framework. 5 | - Low maintenance. Avoid dependencies that could impact the application in a material way. 6 | - Flexibility. Avoid dependencies that take over the control flow of the application. 7 | - Easy to change. Tests run fast. Tests are behavioural. 8 | - Functional leaning. Avoid strict functional programming. 9 | - Enables merciless refactoring. 10 | - Embrace JavaScript as a dynamically typed language. 11 | 12 | #### Further reading 13 | 14 | - [Refactoring - Martin Fowler](https://martinfowler.com/tags/refactoring.html) 15 | - [Refactor Mercilessly - Ward Cunningham](https://wiki.c2.com/?RefactorMercilessly) 16 | -------------------------------------------------------------------------------- /readme-gen/assets/sections/getting-started.md: -------------------------------------------------------------------------------- 1 | ### Tasks 2 | 3 | - Run the tests: `./task test` 4 | - Start dev server: `./task serve` 5 | - List all tasks: `ls ./tasks` 6 | 7 | ### iTerm2 pre-configured layout (macOS only) 8 | 9 | [iTermocil](https://github.com/TomAnthony/itermocil) allows you to setup pre-configured layouts of windows and panes in [iTerm2](https://www.iterm2.com/). 10 | 11 | - Install iTermocil and launch the pre-configured layout: `./task itermocil` 12 | 13 | <%- await lib.renderImage('readme-files/itermocil.png', 'iTerm2 pre-configured layout') %> 14 | -------------------------------------------------------------------------------- /readme-gen/assets/sections/launching.md: -------------------------------------------------------------------------------- 1 | The application is built with a module bundler called [Parcel](https://parceljs.org/). 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. 2 | 3 | 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. 4 | 5 | The application is split into 2 top-level directories: public and src. 6 | 7 | - __public__ contains the entry HTML file, static assets such as images and CSS, and the minimum amount of code needed to launch 'the application'. 8 | - __src__ contains all the code comprising 'the application'. 9 | 10 | 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. 11 | 12 | The following code is referenced by index.html and launches the application: 13 | 14 | <%- await lib.renderCode(lib.fetchCode('src/app.js')) %> 15 | 16 | #### Launch sequence 17 | 18 | 1. 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. 19 | 2. At run time, the compose function is invoked with the global window object and config, returning the initialised application modules. 20 | 3. The modules are assigned to `window.app` for demonstration and debugging purposes. 21 | 4. The startup function is invoked with a callback receiving an instance of the root component, app, which is then appended to the document body. 22 | 23 | Note: `window.agileavatars` changed to `window.app`. 24 | 25 | <%- await lib.renderImage('readme-files/console-modules.png', 'Application modules logged to the console') %> 26 | 27 | <%- await lib.renderImage('readme-files/console-state.png', 'Application state logged to the console') %> 28 | -------------------------------------------------------------------------------- /readme-gen/assets/sections/modules.md: -------------------------------------------------------------------------------- 1 | The application is composed of architectural components called modules. Each module has a separate responsibility and may be composed with collaborating modules. 2 | 3 | On the file system, a module is simply a directory of sources files that follow some simple rules: 4 | 5 | - Each file and subdirectory (i.e. nested index.js) is loaded by index.js in the same directory. 6 | - Each index.js exports an aggregate object of all files and directories loaded. 7 | - Each file exports a function, so file names tend to be function names. 8 | - Where a module is to be composed with collaborating modules, exported functions must be curried to first accept the collaborators. 9 | 10 | #### Example: Root index.js for components module 11 | 12 | <%- await lib.renderCode(lib.fetchCode('src/modules/components/index.js')) %> 13 | 14 | #### Example: Curried function accepting collaborators 15 | 16 | <%- await lib.renderCode(lib.fetchCode('src/modules/components/tag-list/tag/components/tag-name.js')) %> 17 | 18 | This design has some interesting implications: 19 | 20 | - Any source file is only referenced and loaded once in the entire application making it easier to move files around. 21 | - 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. 22 | - Remove the noise of many require/import statements at the top of any file. 23 | - No backtracking paths, i.e. `..` helps reduce cognitive load (for me anyway!). 24 | - The approach to index.js forms a pattern which can be automated with code generation. See [module-indexgen](https://github.com/mattriley/agileavatars#-module-indexgen) in the list of development dependencies. 25 | 26 | ### Detecting inappropriate coupling 27 | 28 | 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. 29 | -------------------------------------------------------------------------------- /readme-gen/assets/sections/technical-constraints.md: -------------------------------------------------------------------------------- 1 | ### General 2 | 3 | - No languages that compile to JavaScript, e.g. TypeScript. 4 | - No frameworks, e.g. Angular, Vue. 5 | - No view rendering libraries, e.g. React. 6 | - No CSS-in-JS libraries, e.g. Styled Components. 7 | - No CSS pre-processors, e.g. SASS, SCSS. 8 | - No state management libraries, e.g. Flux, Redux. 9 | - No functional programming libraries, e.g. Rambda, Immutable. 10 | - No general purpose utility libraries, e.g. Lodash, Underscore. 11 | - No task runners, e.g. Grunt, Gulp. 12 | - No globals. Access to _window_ strictly controlled. 13 | - No classes/prototypes. 14 | 15 | Further reading: 16 | 17 | - [List of languages that compile to JS](https://github.com/jashkenas/coffeescript/wiki/List-of-languages-that-compile-to-JS) 18 | - [The Brutal Lifecycle of JavaScript Frameworks - Ian Allen](https://stackoverflow.blog/2018/01/11/brutal-lifecycle-javascript-frameworks/) 19 | - [You Might Not Need TypeScript (or Static Types) - Eric Elliott](https://medium.com/javascript-scene/you-might-not-need-typescript-or-static-types-aa7cb670a77b) 20 | - [The Shocking Secret About Static Types - Eric Elliott](https://medium.com/javascript-scene/the-shocking-secret-about-static-types-514d39bf30a3) 21 | - [The TypeScript Tax - Eric Elliot](https://medium.com/javascript-scene/the-typescript-tax-132ff4cb175b) 22 | 23 | ### Testing 24 | 25 | - Optimised for speed/fast feedback. Single digit seconds for entire suite. 26 | - No compilation/pre-processing required to run tests. 27 | - No globals. e.g. Mocha, Jest. 28 | - No hooks. e.g. _beforeEach_, _afterEach_. 29 | - No BDD-style assertion libraries, e.g. _should_ or _expect_ found in Mocha, Jest. 30 | - No mocking libraries, e.g. Sinon, Jest. 31 | - No circumvention of the module loading system, e.g. Rewire, Proxyquire, Jest. 32 | 33 | #### Further reading 34 | 35 | - [Mocks Aren't Stubs - Martin Fowler](https://martinfowler.com/articles/mocksArentStubs.html) 36 | - [Classical and Mockist Testing](https://martinfowler.com/articles/mocksArentStubs.html#ClassicalAndMockistTesting) 37 | - [Mockists Are Dead. Long Live Classicists - Fabio Pereria, ThoughtWorks](https://www.thoughtworks.com/insights/blog/mockists-are-dead-long-live-classicists) 38 | - [TDD test suites should run in 10 seconds or less - Mark Seemann](https://blog.ploeh.dk/2012/05/24/TDDtestsuitesshouldrunin10secondsorless/) 39 | - [I strongly recommend that you skip all BDD style assertion libraries - Eric Elliott](https://medium.com/@_ericelliott/i-strongly-recommend-that-you-skip-all-bdd-style-assertion-libraries-including-code-acae26344d4) 40 | - [Mocking is a code smell - Eric Elliott](https://medium.com/javascript-scene/mocking-is-a-code-smell-944a70c90a6a) 41 | -------------------------------------------------------------------------------- /readme-gen/assets/sections/view-rendering.md: -------------------------------------------------------------------------------- 1 | View rendering is achieved primarily using the DOM API - `document.createElement`, and by exception using HTML strings - `element.innerHTML`. 2 | 3 | ## DOM API - document.createElement 4 | 5 | Creating elements with the DOM API usually involves: 6 | 7 | - Creating an element, `document.createElement('div')` 8 | - Assigning a class name, `element.className = 'myclass'` 9 | - Assigning properties, `element.prop1 = 'foo'` 10 | - Appending child elements, `element.append(child1, child2)` 11 | - Adding event listeners, `element.addEventListener('click', clickHandler)` 12 | 13 | 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`. 14 | 15 | ### Creating elements with el 16 | 17 | `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. 18 | 19 | #### Example: Usage of el 20 | 21 | ```js 22 | const $div = el('div', 'myclass', { prop1: 'foo', prop2: 'bar' }) 23 | .append(child1, child2) 24 | .addEventListener('focus', focusHandler) 25 | .addEventListener('click', clickHandler); 26 | ``` 27 | 28 | The equivalent without `el`: 29 | 30 | ```js 31 | const $div = document.createElement('div'); 32 | $div.className = 'myclass'; 33 | $div.prop1 = 'foo'; 34 | $div.prop2 = 'bar'; 35 | $div.append(child1, child2); 36 | $div.addEventListener('focus', focusHandler); 37 | $div.addEventListener('click', clickHandler); 38 | ``` 39 | 40 | #### Source: el 41 | 42 | <%- await lib.renderCode(lib.fetchCode('src/modules/ui/el.js')) %> 43 | 44 | ### Observations 45 | 46 | #### No id required on elements. No need to query for elements. 47 | 48 | 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. 49 | 50 | ## HTML strings - element.innerHTML 51 | 52 | `element.innerHTML` is used by exception, where HTML is used primarily for marking up blocks of content. 53 | 54 | #### Example: Usage of innerHTML for content 55 | 56 | This example uses `el` to create an element, but assigns a HTML string to `innerHTML` rather than appending child elements. 57 | 58 | <%- await lib.renderCode(lib.fetchCode('src/modules/components/tips/naming.js')) %> 59 | -------------------------------------------------------------------------------- /readme-gen/assets/static/console-modules.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/readme-gen/assets/static/console-modules.png -------------------------------------------------------------------------------- /readme-gen/assets/static/console-state.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/readme-gen/assets/static/console-state.png -------------------------------------------------------------------------------- /readme-gen/assets/static/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/readme-gen/assets/static/demo.gif -------------------------------------------------------------------------------- /readme-gen/assets/static/itermocil.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/readme-gen/assets/static/itermocil.png -------------------------------------------------------------------------------- /readme-gen/compose.js: -------------------------------------------------------------------------------- 1 | const composer = require('module-composer'); 2 | const modules = require('./modules'); 3 | 4 | module.exports = ({ readmeGenLib, io, target }) => { 5 | const { compose } = composer({ io, ...modules }); 6 | return compose('renderers', { readmeGenLib, io, target }); 7 | }; 8 | -------------------------------------------------------------------------------- /readme-gen/modules/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | renderers: require('./renderers') 3 | }; 4 | -------------------------------------------------------------------------------- /readme-gen/modules/renderers/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | renderCollaborators: require('./render-collaborators'), 3 | renderDependencies: require('./render-dependencies'), 4 | renderDependency: require('./render-dependency'), 5 | renderDependencyConstraints: require('./render-dependency-constraints'), 6 | renderIndex: require('./render-index'), 7 | renderModules: require('./render-modules') 8 | }; 9 | -------------------------------------------------------------------------------- /readme-gen/modules/renderers/render-collaborators.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ target }) => async ({ moduleName }) => { 2 | 3 | const { dependencies } = target.composition; 4 | const names = Object.keys(dependencies); 5 | 6 | const rows = names.map(name => { 7 | const deps = dependencies[name]; 8 | const allowed = deps.filter(n => names.includes(n)).sort().filter(n => n !== name); 9 | const notAllowed = names.filter(n => !deps.includes(n)).sort().filter(n => n !== name); 10 | return { name, allowed, notAllowed }; 11 | }); 12 | 13 | const row = rows.find(row => row.name === moduleName); 14 | const allowed = row ? row.allowed.join(' ') : ''; 15 | const notAllowed = row ? row.notAllowed.join(' ') : ''; 16 | return '```diff\n+ ' + allowed + '\n- ' + notAllowed + '\n```'; 17 | 18 | }; 19 | -------------------------------------------------------------------------------- /readme-gen/modules/renderers/render-dependencies.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ target, renderers }) => packageKey => { 2 | 3 | const results = Object.entries(target.package[packageKey]).map(([name]) => { 4 | return renderers.renderDependency({ name }); 5 | }); 6 | 7 | return results.join('\n'); 8 | }; 9 | -------------------------------------------------------------------------------- /readme-gen/modules/renderers/render-dependency-constraints.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ target }) => () => { 2 | 3 | const listItems = Object.values(target.dependencyConstraints).map(desc => `- ${desc}`); 4 | return listItems.join('\n'); 5 | 6 | }; 7 | -------------------------------------------------------------------------------- /readme-gen/modules/renderers/render-dependency.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ target }) => ({ name }) => { 2 | 3 | const p = target.dependencies.packages[name] || {}; 4 | const numDependencies = Object.keys(p.dependencies ?? {}).length; 5 | const icon = numDependencies === 0 ? ':boom:' : (numDependencies > 9 ? ':warning:' : ':white_check_mark:'); 6 | 7 | const headerLines = [ 8 | `## ${name}`, 9 | '', 10 | `> ${p.description}`, 11 | '', 12 | `- Homepage: ${p.homepage}`, 13 | `- __${numDependencies}__ dependenc${numDependencies === 1 ? 'y' : 'ies'} ${icon}` 14 | ]; 15 | 16 | const renderUsedForLines = dep => { 17 | return [ 18 | '#### Used for', 19 | '', 20 | dep.usedFor 21 | ]; 22 | }; 23 | 24 | const renderCommentLines = dep => { 25 | const commentLines = Object.entries(dep.comments).map(([constraintKey, comment]) => { 26 | const constraint = target.dependencyConstraints[constraintKey]; 27 | return `- __${constraint}__\\\n${comment}\n`; 28 | }); 29 | return [ 30 | '#### Comments', 31 | '', 32 | ...commentLines 33 | ]; 34 | }; 35 | 36 | const renderAlternativesConsideredLines = dep => { 37 | const lines = Object.entries(dep.alternativesConsidered).map(([name, comment]) => { 38 | return `- __${name}__\\\n${comment}\n`; 39 | }); 40 | return [ 41 | '#### Alternatives considered', 42 | '', 43 | ...lines 44 | ]; 45 | }; 46 | 47 | const dep = target.dependencies.dependencies[name]; 48 | const usedForLines = dep?.usedFor ? renderUsedForLines(dep) : []; 49 | const commentLines = dep?.comments ? renderCommentLines(dep) : []; 50 | const alternativesConsideredLines = dep?.alternativesConsidered ? renderAlternativesConsideredLines(dep) : []; 51 | return [headerLines, usedForLines, commentLines, alternativesConsideredLines].map(s => s.join('\n')).join('\n\n'); 52 | 53 | }; 54 | -------------------------------------------------------------------------------- /readme-gen/modules/renderers/render-index.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | module.exports = ({ target }) => async (name, opts = { maxDepth: 2 }) => { 4 | const { flatten } = await import('flat'); 5 | const keys = Object.keys(flatten(target.composition.modules[name], opts)).sort(); 6 | const half = Math.ceil(keys.length / 2); 7 | const firstHalf = keys.splice(0, half); 8 | const secondHalf = keys.splice(-half); 9 | const z = _.flatten(_.zip(firstHalf, secondHalf)).filter(element => element); 10 | const chunks = _.chunk(z, 2); 11 | const lines = chunks.map(items => items.map(item => item.padEnd(48, ' ')).join('')); 12 | return ['```', ...lines, '```'].join('\n'); 13 | 14 | }; 15 | -------------------------------------------------------------------------------- /readme-gen/modules/renderers/render-modules.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ readmeGenLib, target, io, renderers }) => async () => { 2 | 3 | 4 | const moduleNames = Object.keys(target.composition.dependencies); 5 | 6 | const modules = await Promise.all(moduleNames.map(async name => { 7 | 8 | const renderIndex = opts => renderers.renderIndex(name, opts); 9 | const renderCollaborators = () => renderers.renderCollaborators({ moduleName: name }); 10 | 11 | const template = target.moduleTemplates[name] || ''; 12 | 13 | const content = await io.ejs.render(template, { lib: { ...readmeGenLib, renderIndex, renderCollaborators } }, { async: true }); 14 | const title = `## ${name}\n`; 15 | return [title, content].join('\n\n'); 16 | })); 17 | 18 | return modules.join('\n'); 19 | 20 | }; 21 | -------------------------------------------------------------------------------- /readme-gen/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "commonjs" 3 | } 4 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import './css/*.css'; // eslint-disable-line import/no-unresolved 2 | import compose from './compose'; 3 | 4 | const { startup } = compose({ window }); 5 | const app = startup.start(); 6 | document.getElementById('app').append(app); 7 | -------------------------------------------------------------------------------- /src/compose.js: -------------------------------------------------------------------------------- 1 | import composer from 'module-composer'; 2 | import modules from './modules/index.js'; 3 | import defaultConfig from './default-config.js'; 4 | 5 | export default ({ window, config, overrides }) => { 6 | 7 | const { compose } = composer(modules, { defaultConfig, config, overrides }); 8 | 9 | const { util } = compose.asis('util'); 10 | const { storage } = compose.asis('storage'); 11 | 12 | // Data 13 | const { stores } = compose('stores', { storage }); 14 | const { subscriptions } = compose('subscriptions', { stores, util }); 15 | 16 | // Domain 17 | const { core } = compose.deep('core', { util }); 18 | const { io } = compose('io', { window }); 19 | const { services } = compose.deep('services', { subscriptions, stores, core, io, util }); 20 | 21 | // Presentation 22 | const { ui } = compose('ui', { window }); 23 | const { elements } = compose('elements', { ui, util }); 24 | const { vendorComponents } = compose('vendorComponents', { ui, window }); 25 | const { components } = compose.deep('components', { io, ui, elements, vendorComponents, services, subscriptions, util }); 26 | const { styles } = compose('styles', { ui, subscriptions }); 27 | 28 | // Startup 29 | compose('diagnostics', { stores, util }); 30 | return compose('startup', { ui, components, styles, services, subscriptions, stores, util, window }); 31 | 32 | }; 33 | -------------------------------------------------------------------------------- /src/css/app.css: -------------------------------------------------------------------------------- 1 | 2 | /* Prevent background scrolling while modal visible */ 3 | .app.modal-true { 4 | width: 100%; 5 | height: 100%; 6 | overflow: hidden; 7 | } 8 | 9 | /* Workaround inability to drop file over body in Chrome and Firefox */ 10 | .dropzone { 11 | min-height: 100%; 12 | } 13 | 14 | .welcome { 15 | text-align: center; 16 | } 17 | -------------------------------------------------------------------------------- /src/css/control-panel.css: -------------------------------------------------------------------------------- 1 | @media print { 2 | .control-panel { 3 | display: none; 4 | } 5 | } 6 | 7 | .control-panel { 8 | text-align: center; 9 | background-color: whitesmoke; 10 | padding: 10px 0; 11 | } 12 | 13 | /* Prevent passive tag image appearing above colour picker (filter affects stacking context) */ 14 | .control-panel { 15 | z-index: 7; 16 | } 17 | 18 | /* Stick to top of screen while scrolling */ 19 | .control-panel { 20 | position: sticky; 21 | top: 0; 22 | } 23 | 24 | .control-panel > * { 25 | margin: 10px 0; 26 | transition: all ease-in-out 1s; 27 | } 28 | 29 | .control-panel > .visible-false { 30 | margin: 0; 31 | opacity: 0; 32 | max-height: 0; 33 | } 34 | 35 | .control-panel > .visible-true { 36 | opacity: 1; 37 | max-height: 100%; 38 | } 39 | -------------------------------------------------------------------------------- /src/css/gravatar-spinner.css: -------------------------------------------------------------------------------- 1 | .lds-dual-ring { 2 | display: inline-block; 3 | height: 2.5em; 4 | width: 2.5em; 5 | } 6 | 7 | .lds-dual-ring:after { 8 | content: ' '; 9 | display: block; 10 | height: 2.5em; 11 | width: 2.5em; 12 | margin: 1px; 13 | border-radius: 50%; 14 | border: 5px solid cornflowerblue; 15 | border-color: cornflowerblue transparent cornflowerblue transparent; 16 | animation: lds-dual-ring 1.2s linear infinite; 17 | } 18 | 19 | @keyframes lds-dual-ring { 20 | 0% { 21 | transform: rotate(0deg); 22 | } 23 | 100% { 24 | transform: rotate(360deg); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/css/gravatar.css: -------------------------------------------------------------------------------- 1 | .modal-title a.about-gravatar { 2 | font-size: smaller; 3 | font-weight: normal; 4 | font-style: italic; 5 | display: inline-block; 6 | padding-top: .2rem; 7 | } 8 | 9 | .gravatar-title { 10 | margin-right: 1.5rem; 11 | } 12 | 13 | .gravatar label, 14 | .gravatar .label { 15 | display: block; 16 | margin-bottom: 1rem; 17 | } 18 | 19 | .gravatar .freetext { 20 | width: 100%; 21 | height: 200px; 22 | } 23 | 24 | .gravatar .freetext:disabled { 25 | background-color: gainsboro; 26 | } 27 | 28 | .gravatar .fallback { 29 | border: solid 3px white; 30 | padding: 1px; 31 | margin: 2px; 32 | cursor: pointer; 33 | transition: all 0.2s ease-in-out; 34 | } 35 | 36 | .gravatar .fallback:hover { 37 | transform: scale(1.1); 38 | } 39 | 40 | .gravatar .fallback.selected-true { 41 | border-color: cornflowerblue; 42 | } 43 | -------------------------------------------------------------------------------- /src/css/header.css: -------------------------------------------------------------------------------- 1 | @media print { 2 | .dev-bar .wip-bar { 3 | display: none; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/css/image-upload-options.css: -------------------------------------------------------------------------------- 1 | .image-upload-options { 2 | font-weight: bold; 3 | } 4 | 5 | .image-upload-options a { 6 | color: cornflowerblue; 7 | } 8 | 9 | .image-upload-options > *:after { 10 | padding-left: 1rem; 11 | padding-right: 1rem; 12 | content: '●' 13 | } 14 | 15 | .image-upload-options > *:last-child:after { 16 | content: ''; 17 | padding: 0; 18 | } 19 | -------------------------------------------------------------------------------- /src/css/nil-role.css: -------------------------------------------------------------------------------- 1 | @media print { 2 | .tag.nil-role-true .role-name { 3 | display: none; 4 | } 5 | } 6 | 7 | .tag.nil-role-true .role-name { 8 | border: 1px dashed gray; 9 | color: lightgray; 10 | background-color: white; 11 | } 12 | 13 | .tag.nil-role-true .role-name:before { 14 | content: 'role'; 15 | } 16 | 17 | .tag.nil-role-true .role-name:focus:before { 18 | content: ''; 19 | } 20 | 21 | .tag.nil-role-true .role-name:focus { 22 | color: black; 23 | } 24 | -------------------------------------------------------------------------------- /src/css/options.css: -------------------------------------------------------------------------------- 1 | .options-bar { 2 | line-height: 2em; 3 | } 4 | 5 | .options-bar * { 6 | font-size: small; 7 | } 8 | 9 | .options-bar .group { 10 | margin: 0 10px; 11 | white-space: nowrap; 12 | } 13 | 14 | .options-bar label { 15 | padding: 0 5px; 16 | } 17 | 18 | .options-bar .label { 19 | margin-right: 5px; 20 | } 21 | 22 | .shape-option { 23 | display: inline-block; 24 | margin: 0 4px -4px 0; 25 | border: solid 3px gray; 26 | width: 12px; 27 | height: 12px; 28 | cursor: pointer; 29 | transition: all 0.2s ease-in-out; 30 | } 31 | 32 | .shape-option:hover { 33 | transform: scale(1.2); 34 | } 35 | 36 | .shape-option.selected-true { 37 | border-color: cornflowerblue; 38 | } 39 | 40 | input[type=number] { 41 | width: 50px; 42 | } 43 | -------------------------------------------------------------------------------- /src/css/roles.css: -------------------------------------------------------------------------------- 1 | .role-customiser { 2 | border: 1px solid lightgray; 3 | background-color: white; 4 | padding: 5px; 5 | margin: 5px; 6 | display: inline-block; 7 | white-space: nowrap; 8 | } 9 | 10 | .role-name { 11 | color: white; 12 | border-radius: 25px; 13 | padding-left: 7px; 14 | padding-right: 7px; 15 | padding-top: 3px; 16 | padding-bottom: 2px; 17 | text-transform: uppercase; 18 | font-size: 14px; 19 | font-weight: bold; 20 | /* text-shadow: #000 0px 0px 2px; */ 21 | } 22 | 23 | .color-picker { 24 | display: inline-block; 25 | padding-top: 3px; 26 | padding-bottom: 2px; 27 | font-size: smaller; 28 | width: 10px; 29 | cursor: pointer; 30 | margin-left: 5px; 31 | margin-right: 2px; 32 | } 33 | 34 | .color-picker::before { 35 | color: gray; 36 | content: '\25bc'; 37 | /* unicode zero width space character */ 38 | } 39 | -------------------------------------------------------------------------------- /src/css/site.css: -------------------------------------------------------------------------------- 1 | /* https://developer.mozilla.org/en-US/docs/Web/CSS/var */ 2 | 3 | body { 4 | -webkit-print-color-adjust: exact !important; 5 | -moz-user-select: none; 6 | /* fixes Firefox issue */ 7 | font-family: helvetica, sans-serif; 8 | margin: 0; 9 | padding: 0; 10 | color: #444444; 11 | } 12 | 13 | 14 | a { 15 | color: white; 16 | cursor: pointer; 17 | text-decoration: none; 18 | } 19 | 20 | a:hover { 21 | text-decoration: underline; 22 | } 23 | 24 | span[contenteditable=true] { 25 | cursor: text; 26 | } 27 | -------------------------------------------------------------------------------- /src/css/tags.css: -------------------------------------------------------------------------------- 1 | .tag-list { 2 | display: grid; 3 | grid-auto-rows: 1fr; 4 | margin: 5px; 5 | } 6 | 7 | .tag { 8 | text-align: center; 9 | color: black; 10 | font-weight: bold; 11 | padding: 5px 0; 12 | border: 1px dashed silver; 13 | 14 | } 15 | 16 | .tag * { 17 | margin: 0 auto; 18 | } 19 | 20 | .tag .layout .group { 21 | display: block; 22 | margin: 8px; 23 | } 24 | 25 | .tag-name { 26 | margin-bottom: 5px; 27 | /* for when it wraps so it doesnt get too close to role */ 28 | } 29 | 30 | .tag-name:empty { 31 | border: 1px dashed gray; 32 | width: 20px; 33 | } 34 | 35 | .tag .role-name { 36 | border: 1px solid transparent; 37 | margin: 0 3px; 38 | max-width: 100%; 39 | /* forces name to wrap and no exceed bounds of tag */ 40 | } 41 | 42 | 43 | .tag-image { 44 | box-sizing: border-box; 45 | border: solid 5px transparent; 46 | background-clip: content-box; 47 | background-position: center; 48 | background-size: contain; 49 | background-repeat: no-repeat; 50 | height: 100%; 51 | width: 100%; 52 | } 53 | 54 | .tag-name { 55 | display: inline-block; 56 | border: 1px solid transparent; 57 | padding-top: 2px; 58 | padding-bottom: 1px; 59 | margin-left: 3px; 60 | margin-right: 3px; 61 | max-width: 100%; 62 | /* forces name to wrap and no exceed bounds of tag */ 63 | } 64 | 65 | .tag.passive .tag-image { 66 | -webkit-filter: grayscale(100%); 67 | /* Safari 6.0 - 9.0 */ 68 | filter: grayscale(100%); 69 | border-color: silver; 70 | } 71 | 72 | .tag.passive.nil-role-false .role-name { 73 | background-color: silver; 74 | } -------------------------------------------------------------------------------- /src/css/tips.css: -------------------------------------------------------------------------------- 1 | .tips .tip { 2 | margin-bottom: 3em; 3 | } 4 | 5 | .tip .heading { 6 | font-weight: bold; 7 | } 8 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Agile Avatars 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/index.template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | $INDEX_HTML_TITLE 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/modules/components/app.js: -------------------------------------------------------------------------------- 1 | export default ({ io, ui, components, subscriptions }) => () => { 2 | 3 | io.mixpanel.track('pageview'); 4 | 5 | const $$modals = Object.values(components.modals).map(modal => modal()); 6 | 7 | const $container = ui.el('div', 'app').append( 8 | ui.el('div', 'modals').append(...$$modals), 9 | components.header.container(), 10 | components.dropzone().append( 11 | ui.el('div', 'control-panel').append( 12 | components.imageUploadOptions.container(), 13 | components.roleList.container(), 14 | components.optionsBar.container() 15 | ), 16 | components.tagList.container() 17 | ) 18 | ); 19 | 20 | subscriptions.settings.onChange('app', 'modal', modal => { 21 | ui.toggleBoolClass($container, 'modal', modal); 22 | }); 23 | 24 | return $container; 25 | 26 | }; 27 | -------------------------------------------------------------------------------- /src/modules/components/dropzone.js: -------------------------------------------------------------------------------- 1 | export default ({ elements, services }) => () => { 2 | 3 | return elements.dropzone() 4 | .addEventListener('drop', e => { 5 | services.tags.insertFileBatchAsync(e.dataTransfer.files); 6 | }); 7 | 8 | }; 9 | 10 | -------------------------------------------------------------------------------- /src/modules/components/gravatar/actions/container.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, components }) => () => { 2 | 3 | return ui.el('div').append( 4 | components.gravatar.actions.importButton(), 5 | components.gravatar.actions.loading(), 6 | components.gravatar.actions.error() 7 | ); 8 | 9 | }; 10 | -------------------------------------------------------------------------------- /src/modules/components/gravatar/actions/error.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, services, subscriptions }) => () => { 2 | 3 | const $errorMessage = ui.el('div', 'error-message'); 4 | 5 | const $dismiss = ui.el('a', 'dismiss', { textContent: 'Dismiss' }) 6 | .addEventListener('click', services.gravatar.status.to.ready); 7 | 8 | const $error = ui.el('div', 'error').append($errorMessage, $dismiss); 9 | 10 | subscriptions.settings.onChange('gravatar', 'status', () => { 11 | ui.toggleBoolClass($error, 'visible', services.gravatar.status.is.error()); 12 | }); 13 | 14 | subscriptions.settings.onChange('gravatar', 'errorMessage', errorMessage => { 15 | $errorMessage.textContent = errorMessage; 16 | }); 17 | 18 | return $error; 19 | 20 | }; 21 | -------------------------------------------------------------------------------- /src/modules/components/gravatar/actions/import-button.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, services, subscriptions }) => () => { 2 | 3 | const $import = ui.el('button', 'import', { textContent: 'Import' }) 4 | .addEventListener('click', () => { 5 | const { emails, fallback } = services.settings.getGravatar(); 6 | services.tags.insertGravatarBatchAsync(emails, fallback); 7 | }); 8 | 9 | subscriptions.settings.onChange('gravatar', 'status', () => { 10 | ui.toggleBoolClass($import, 'visible', services.gravatar.status.is.ready()); 11 | }); 12 | 13 | subscriptions.settings.onChange('gravatar', 'freetext', freetext => { 14 | $import.disabled = !freetext.trim(); 15 | }); 16 | 17 | return $import; 18 | 19 | }; 20 | -------------------------------------------------------------------------------- /src/modules/components/gravatar/actions/index.js: -------------------------------------------------------------------------------- 1 | import container from './container.js'; 2 | import error from './error.js'; 3 | import importButton from './import-button.js'; 4 | import loading from './loading.js'; 5 | 6 | export default { 7 | container, 8 | error, 9 | importButton, 10 | loading 11 | }; 12 | -------------------------------------------------------------------------------- /src/modules/components/gravatar/actions/loading.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, services, subscriptions }) => () => { 2 | 3 | const $loading = ui.el('div', 'loading-indicator lds-dual-ring'); 4 | 5 | subscriptions.settings.onChange('gravatar', 'status', () => { 6 | ui.toggleBoolClass($loading, 'visible', services.gravatar.status.is.working()); 7 | }); 8 | 9 | return $loading; 10 | 11 | }; 12 | -------------------------------------------------------------------------------- /src/modules/components/gravatar/content/container.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, components }) => () => { 2 | 3 | return ui.el('div', 'gravatar').append( 4 | components.gravatar.content.freetext(), 5 | components.gravatar.content.fallbacks() 6 | ); 7 | 8 | }; 9 | -------------------------------------------------------------------------------- /src/modules/components/gravatar/content/fallback.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, services, subscriptions }) => fallback => { 2 | 3 | const $fallback = ui.el('img', 'fallback', { 4 | title: fallback, 5 | src: `img/gravatar-fallbacks/${fallback}.png` 6 | }).addEventListener('click', () => { 7 | services.gravatar.changeFallback(fallback); 8 | }); 9 | 10 | subscriptions.settings.onChange('gravatar', 'fallback', selectedFallback => { 11 | ui.toggleBoolClass($fallback, 'selected', fallback === selectedFallback); 12 | }); 13 | 14 | return $fallback; 15 | }; 16 | -------------------------------------------------------------------------------- /src/modules/components/gravatar/content/fallbacks.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, components, elements, config }) => () => { 2 | 3 | const $$fallbackOptions = config.gravatar.fallbacks.map(components.gravatar.content.fallback); 4 | const $fallbacks = ui.el('div').append(...$$fallbackOptions); 5 | const labelText = 'Select a generated image style to use in case profile image is not found.'; 6 | return elements.label(labelText, $fallbacks); 7 | 8 | }; 9 | -------------------------------------------------------------------------------- /src/modules/components/gravatar/content/freetext.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, services, subscriptions, elements }) => () => { 2 | 3 | const $freetext = ui.el('textarea', 'freetext') 4 | .addEventListener('input', () => { 5 | services.gravatar.changeFreetext($freetext.value); 6 | }); 7 | 8 | subscriptions.settings.onChange('gravatar', 'freetext', freetext => { 9 | $freetext.value = freetext; 10 | }); 11 | 12 | subscriptions.settings.onChange('gravatar', 'status', () => { 13 | $freetext.disabled = services.gravatar.status.is.working(); 14 | }); 15 | 16 | const labelText = 'Email addresses:'; 17 | 18 | return elements.label(labelText, $freetext); 19 | 20 | }; 21 | -------------------------------------------------------------------------------- /src/modules/components/gravatar/content/index.js: -------------------------------------------------------------------------------- 1 | import container from './container.js'; 2 | import fallback from './fallback.js'; 3 | import fallbacks from './fallbacks.js'; 4 | import freetext from './freetext.js'; 5 | 6 | export default { 7 | container, 8 | fallback, 9 | fallbacks, 10 | freetext 11 | }; 12 | -------------------------------------------------------------------------------- /src/modules/components/gravatar/index.js: -------------------------------------------------------------------------------- 1 | import actions from './actions/index.js'; 2 | import content from './content/index.js'; 3 | import title from './title.js'; 4 | 5 | export default { 6 | actions, 7 | content, 8 | title 9 | }; 10 | -------------------------------------------------------------------------------- /src/modules/components/gravatar/title.js: -------------------------------------------------------------------------------- 1 | export default ({ ui }) => () => { 2 | 3 | const $gravatarTitle = ui.el('span', 'gravatar-title', { 4 | textContent: 'Import images from Gravatar' 5 | }); 6 | 7 | const $aboutGravatar = ui.el('a', 'about-gravatar', { 8 | text: 'What is Gravatar?', 9 | href: 'https://en.gravatar.com/support/what-is-gravatar/', 10 | target: '_blank' 11 | }); 12 | 13 | return ui.el('div').append($gravatarTitle, $aboutGravatar); 14 | 15 | }; 16 | -------------------------------------------------------------------------------- /src/modules/components/header/container.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, components }) => () => { 2 | 3 | return ui.el('header').append(components.header.titleBar()); 4 | 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/components/header/index.js: -------------------------------------------------------------------------------- 1 | import container from './container.js'; 2 | import titleBar from './title-bar.js'; 3 | 4 | export default { 5 | container, 6 | titleBar 7 | }; 8 | -------------------------------------------------------------------------------- /src/modules/components/header/title-bar.js: -------------------------------------------------------------------------------- 1 | export default ({ services, ui, config }) => () => { 2 | 3 | const $tips = ui.el('a', 'tips', { textContent: 'Tips & tricks' }) 4 | .addEventListener('click', () => { 5 | services.settings.changeModal('tips'); 6 | }); 7 | 8 | const $issues = ui.el('a', { 9 | textContent: 'Send feedback', 10 | target: '_blank', 11 | href: config.app.issues 12 | }); 13 | 14 | const $source = ui.el('a', { 15 | textContent: 'Source code', 16 | target: '_blank', 17 | href: config.app.source 18 | }); 19 | 20 | const $devBar = ui.el('dev-bar').append($tips, $issues, $source); 21 | $devBar.setAttribute('app-name', config.app.name); 22 | 23 | return $devBar; 24 | 25 | }; 26 | -------------------------------------------------------------------------------- /src/modules/components/image-upload-options/choose-images.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, services }) => () => { 2 | 3 | const $fileInput = ui.el('input', { 4 | type: 'file', 5 | multiple: true, 6 | accept: 'image/*' 7 | }).addEventListener('change', e => { 8 | services.tags.insertFileBatchAsync(e.target.files); 9 | }); 10 | 11 | const $chooseImages = ui.el('a', { textContent: 'Choose images' }) 12 | .addEventListener('click', e => { 13 | e.preventDefault(); 14 | $fileInput.click(); 15 | e.fileInput = $fileInput; 16 | }); 17 | 18 | return ui.el('span').append($chooseImages); 19 | 20 | }; 21 | 22 | /* FOOTNOTES 23 | 24 | - Interestingly, file input can be triggered which detached from the ui. 25 | - File input is assigned to click event to make it accessible to tests via event propagation. 26 | 27 | */ 28 | -------------------------------------------------------------------------------- /src/modules/components/image-upload-options/container.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, components }) => () => { 2 | 3 | return ui.el('div', 'image-upload-options').append( 4 | ui.el('span', { textContent: 'Drag & drop images' }), 5 | components.imageUploadOptions.chooseImages(), 6 | components.imageUploadOptions.gravatar() 7 | ); 8 | 9 | }; 10 | -------------------------------------------------------------------------------- /src/modules/components/image-upload-options/gravatar.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, services, io }) => () => { 2 | 3 | return ui.el('a', { textContent: 'Import images from Gravatar' }) 4 | .addEventListener('click', () => { 5 | io.mixpanel.track('gravatar-import'); 6 | services.settings.changeModal('gravatar'); 7 | }); 8 | 9 | }; 10 | -------------------------------------------------------------------------------- /src/modules/components/image-upload-options/index.js: -------------------------------------------------------------------------------- 1 | import chooseImages from './choose-images.js'; 2 | import container from './container.js'; 3 | import gravatar from './gravatar.js'; 4 | 5 | export default { 6 | chooseImages, 7 | container, 8 | gravatar 9 | }; 10 | -------------------------------------------------------------------------------- /src/modules/components/index.js: -------------------------------------------------------------------------------- 1 | import app from './app.js'; 2 | import dropzone from './dropzone.js'; 3 | import gravatar from './gravatar/index.js'; 4 | import header from './header/index.js'; 5 | import imageUploadOptions from './image-upload-options/index.js'; 6 | import modal from './modal.js'; 7 | import modals from './modals/index.js'; 8 | import optionsBar from './options-bar/index.js'; 9 | import roleList from './role-list/index.js'; 10 | import tagList from './tag-list/index.js'; 11 | import tips from './tips/index.js'; 12 | 13 | export default { 14 | app, 15 | dropzone, 16 | gravatar, 17 | header, 18 | imageUploadOptions, 19 | modal, 20 | modals, 21 | optionsBar, 22 | roleList, 23 | tagList, 24 | tips 25 | }; 26 | -------------------------------------------------------------------------------- /src/modules/components/modal.js: -------------------------------------------------------------------------------- 1 | export default ({ elements, services, subscriptions }) => ({ name, title, content, actions }) => { 2 | 3 | const onVisibilityChange = callback => { 4 | subscriptions.settings.onChange('app', 'modal', modal => { 5 | const visible = modal === name; 6 | callback(visible); 7 | }); 8 | }; 9 | 10 | return elements.modal({ title, content, actions, onVisibilityChange }) 11 | .addEventListener('dismiss', services.settings.clearModal); 12 | 13 | }; 14 | -------------------------------------------------------------------------------- /src/modules/components/modals/gravatar.js: -------------------------------------------------------------------------------- 1 | export default ({ components }) => () => { 2 | 3 | return components.modal({ 4 | name: 'gravatar', 5 | title: components.gravatar.title(), 6 | content: components.gravatar.content.container(), 7 | actions: components.gravatar.actions.container() 8 | }); 9 | 10 | }; 11 | -------------------------------------------------------------------------------- /src/modules/components/modals/index.js: -------------------------------------------------------------------------------- 1 | import gravatar from './gravatar.js'; 2 | import tips from './tips.js'; 3 | import welcome from './welcome.js'; 4 | 5 | export default { 6 | gravatar, 7 | tips, 8 | welcome 9 | }; 10 | -------------------------------------------------------------------------------- /src/modules/components/modals/tips.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, components }) => () => { 2 | 3 | const { roleShortcut, naming, images, laminating, multiples, badges } = components.tips; 4 | const sequence = [roleShortcut, naming, images, laminating, multiples, badges]; 5 | 6 | const $tips = ui.el('div', 'tips'); 7 | 8 | sequence.forEach(render => { 9 | const $tip = render(); 10 | $tip.className = 'tip'; 11 | const $heading = ui.el('div', 'heading', { textContent: $tip.title }); 12 | $tip.prepend($heading); 13 | $tips.append($tip); 14 | }); 15 | 16 | return components.modal({ 17 | name: 'tips', 18 | content: $tips, 19 | title: 'Tips & tricks' 20 | }); 21 | 22 | }; 23 | -------------------------------------------------------------------------------- /src/modules/components/modals/welcome.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, components, services, config }) => () => { 2 | 3 | const $heading = ui.el('h1', 'welcome-title', { 4 | textContent: `Welcome to ${config.app.name}` 5 | }); 6 | 7 | const $image = ui.el('img', { 8 | src: 'img/welcome.png', 9 | width: 800 10 | }); 11 | 12 | const $continue = ui.el('button', { textContent: 'Continue' }); 13 | $continue.addEventListener('click', services.settings.clearModal); 14 | 15 | const $content = ui.el('div', 'welcome').append($heading, $image); 16 | 17 | return components.modal({ 18 | name: 'welcome', 19 | content: $content, 20 | actions: $continue 21 | }); 22 | 23 | }; 24 | -------------------------------------------------------------------------------- /src/modules/components/options-bar/container.js: -------------------------------------------------------------------------------- 1 | export default ({ components, elements, subscriptions, ui, config }) => () => { 2 | 3 | const $optionsBar = elements.layout({ 4 | layout: config.options.layout, 5 | components: components.optionsBar.options 6 | }); 7 | 8 | $optionsBar.className = 'options-bar visible-false'; 9 | 10 | subscriptions.tags.onFirstInsert(() => { 11 | ui.toggleBoolClass($optionsBar, 'visible', true); 12 | }); 13 | 14 | return $optionsBar; 15 | 16 | }; 17 | -------------------------------------------------------------------------------- /src/modules/components/options-bar/index.js: -------------------------------------------------------------------------------- 1 | import container from './container.js'; 2 | import numberOption from './number-option.js'; 3 | import options from './options/index.js'; 4 | import shapeOption from './shape-option.js'; 5 | 6 | export default { 7 | container, 8 | numberOption, 9 | options, 10 | shapeOption 11 | }; 12 | -------------------------------------------------------------------------------- /src/modules/components/options-bar/number-option.js: -------------------------------------------------------------------------------- 1 | export default ({ elements, services, subscriptions, util, config }) => optionName => { 2 | 3 | const { min, max, step } = config.options[optionName]; 4 | 5 | const $number = elements.number({ min, max, step }) 6 | .addEventListener('change', e => { 7 | services.settings.changeOption(optionName, parseInt(e.target.value)); 8 | }); 9 | 10 | subscriptions.settings.onChange('options', optionName, val => { 11 | $number.value = val; 12 | }); 13 | 14 | const labelText = util.upperFirst(optionName); 15 | return elements.label(labelText, $number); 16 | 17 | }; 18 | -------------------------------------------------------------------------------- /src/modules/components/options-bar/options/index.js: -------------------------------------------------------------------------------- 1 | import modes from './modes.js'; 2 | import outline from './outline.js'; 3 | import shapes from './shapes.js'; 4 | import size from './size.js'; 5 | import sort from './sort.js'; 6 | import spacing from './spacing.js'; 7 | 8 | export default { 9 | modes, 10 | outline, 11 | shapes, 12 | size, 13 | sort, 14 | spacing 15 | }; 16 | -------------------------------------------------------------------------------- /src/modules/components/options-bar/options/modes.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, components, config }) => () => { 2 | 3 | const $$modes = config.options.modes.map(components.optionsBar.numberOption); 4 | return ui.el('span').append(...$$modes); 5 | 6 | }; 7 | -------------------------------------------------------------------------------- /src/modules/components/options-bar/options/outline.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, elements, services, subscriptions }) => () => { 2 | 3 | const $showOutline = ui.el('input', { type: 'checkbox' }) 4 | .addEventListener('change', () => { 5 | services.settings.changeOption('outline', $showOutline.checked); 6 | }); 7 | 8 | subscriptions.settings.onChange('options', 'outline', outline => { 9 | $showOutline.checked = outline; 10 | }); 11 | 12 | return elements.label($showOutline, 'Show outline'); 13 | 14 | }; 15 | -------------------------------------------------------------------------------- /src/modules/components/options-bar/options/shapes.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, components, config }) => () => { 2 | 3 | const $$shapes = config.options.shapes.map(components.optionsBar.shapeOption); 4 | return ui.el('span').append(...$$shapes); 5 | 6 | }; 7 | -------------------------------------------------------------------------------- /src/modules/components/options-bar/options/size.js: -------------------------------------------------------------------------------- 1 | export default ({ components }) => () => components.optionsBar.numberOption('size'); 2 | -------------------------------------------------------------------------------- /src/modules/components/options-bar/options/sort.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, elements, services, subscriptions, config }) => () => { 2 | 3 | const $$options = Object.entries(config.options.sort).map(([value, textContent]) => { 4 | return ui.el('option', { value, textContent }); 5 | }); 6 | 7 | const $keepSorted = ui.el('select') 8 | .append(...$$options) 9 | .addEventListener('change', () => { 10 | services.settings.changeOption('sort', $keepSorted.value); 11 | }); 12 | 13 | subscriptions.settings.onChange('options', 'sort', sort => { 14 | $keepSorted.value = sort; 15 | }); 16 | 17 | return elements.label('Keep sorted by', $keepSorted); 18 | 19 | }; 20 | -------------------------------------------------------------------------------- /src/modules/components/options-bar/options/spacing.js: -------------------------------------------------------------------------------- 1 | export default ({ components }) => () => components.optionsBar.numberOption('spacing'); 2 | -------------------------------------------------------------------------------- /src/modules/components/options-bar/shape-option.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, subscriptions, services, config }) => shapeName => { 2 | 3 | const $shape = ui.el('span', 'shape-option', { 4 | title: `Change shape to ${shapeName}`, 5 | tabIndex: 0 6 | }).addEventListener('click', () => { 7 | services.settings.changeOption('shape', shapeName); 8 | }).addEventListener('keydown', e => { 9 | if (['Enter', 'Space'].includes(e.code)) { 10 | $shape.click(); 11 | e.preventDefault(); 12 | } 13 | }); 14 | 15 | const borderRadius = config.options.shapeRadius[shapeName] || 0; 16 | $shape.style.borderRadius = `${borderRadius}%`; 17 | 18 | subscriptions.settings.onChange('options', 'shape', selectedShape => { 19 | ui.toggleBoolClass($shape, 'selected', shapeName === selectedShape); 20 | }); 21 | 22 | return $shape; 23 | 24 | }; 25 | -------------------------------------------------------------------------------- /src/modules/components/role-list/container.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, components, subscriptions }) => () => { 2 | 3 | const $roleList = ui.el('div', 'role-list visible-false'); 4 | 5 | subscriptions.roles.onInsert(roleId => { 6 | const $role = components.roleList.roleCustomiser.container(roleId); 7 | $roleList.append($role); 8 | }); 9 | 10 | subscriptions.roles.onFirstInsert(() => { 11 | ui.toggleBoolClass($roleList, 'visible', true); 12 | }); 13 | 14 | return $roleList; 15 | 16 | }; 17 | -------------------------------------------------------------------------------- /src/modules/components/role-list/index.js: -------------------------------------------------------------------------------- 1 | import container from './container.js'; 2 | import roleCustomiser from './role-customiser/index.js'; 3 | 4 | export default { 5 | container, 6 | roleCustomiser 7 | }; 8 | -------------------------------------------------------------------------------- /src/modules/components/role-list/role-customiser/container.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, components, services }) => roleId => { 2 | 3 | const $masterRoleName = components.roleList.roleCustomiser.masterRoleName(roleId); 4 | const $colorPicker = components.roleList.roleCustomiser.roleColorPicker(roleId); 5 | 6 | const isNilRole = services.roles.isNilRole(roleId); 7 | 8 | return ui.el('span', `role-customiser role${roleId}`, { hidden: isNilRole }) 9 | .append($masterRoleName, $colorPicker); 10 | 11 | }; 12 | -------------------------------------------------------------------------------- /src/modules/components/role-list/role-customiser/index.js: -------------------------------------------------------------------------------- 1 | import container from './container.js'; 2 | import masterRoleName from './master-role-name.js'; 3 | import roleColorPicker from './role-color-picker.js'; 4 | 5 | export default { 6 | container, 7 | masterRoleName, 8 | roleColorPicker 9 | }; 10 | -------------------------------------------------------------------------------- /src/modules/components/role-list/role-customiser/master-role-name.js: -------------------------------------------------------------------------------- 1 | export default ({ elements, services, subscriptions }) => roleId => { 2 | 3 | const $roleName = elements.editableSpan(`role-name role${roleId}`) 4 | .addEventListener('change', () => { 5 | services.roles.changeRoleName(roleId, $roleName.textContent); 6 | }); 7 | 8 | subscriptions.roles.onChange(roleId, 'roleName', roleName => { 9 | $roleName.textContent = roleName; 10 | }); 11 | 12 | return $roleName; 13 | 14 | }; 15 | -------------------------------------------------------------------------------- /src/modules/components/role-list/role-customiser/role-color-picker.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, vendorComponents, services }) => roleId => { 2 | 3 | const role = services.roles.getRole(roleId); 4 | 5 | return vendorComponents.vanillaPicker({ 6 | parent: ui.el('div', 'color-picker'), 7 | color: role.color, 8 | onChange: e => services.roles.changeRoleColor(roleId, e.hex) 9 | }); 10 | 11 | }; 12 | -------------------------------------------------------------------------------- /src/modules/components/tag-list/container.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, components, services, subscriptions, util, config }) => () => { 2 | 3 | const $$tags = new Map(); 4 | 5 | const $tags = ui.el('div', 'tag-list'); 6 | 7 | subscriptions.tagInstances.onInsert(tagInstanceId => { 8 | const $tag = components.tagList.tag.container(tagInstanceId); 9 | $$tags.set(tagInstanceId, $tag); 10 | $tags.append($tag); 11 | delayedSort(); 12 | }); 13 | 14 | subscriptions.tagInstances.onBeforeRemove(tagInstanceId => { 15 | $$tags.get(tagInstanceId).remove(); 16 | $$tags.delete(tagInstanceId); 17 | }); 18 | 19 | const sort = () => { 20 | ui.refocus(() => { 21 | services.tags.sortTagInstances().forEach(tagInstance => { 22 | $tags.append($$tags.get(tagInstance.id)); 23 | }); 24 | }); 25 | }; 26 | 27 | const delayedSort = util.debounce( 28 | sort, 29 | config.debounce.sortTagList 30 | ); 31 | 32 | subscriptions.settings.onChange('options', 'sort', sort); 33 | subscriptions.tagInstances.onChangeAny('tagName', delayedSort); 34 | subscriptions.tagInstances.onChangeAny('roleName', delayedSort); 35 | 36 | return $tags; 37 | 38 | }; 39 | -------------------------------------------------------------------------------- /src/modules/components/tag-list/index.js: -------------------------------------------------------------------------------- 1 | import container from './container.js'; 2 | import tag from './tag/index.js'; 3 | 4 | export default { 5 | container, 6 | tag 7 | }; 8 | -------------------------------------------------------------------------------- /src/modules/components/tag-list/tag/components/index.js: -------------------------------------------------------------------------------- 1 | import roleName from './role-name.js'; 2 | import tagImage from './tag-image.js'; 3 | import tagName from './tag-name.js'; 4 | 5 | export default { 6 | roleName, 7 | tagImage, 8 | tagName 9 | }; 10 | -------------------------------------------------------------------------------- /src/modules/components/tag-list/tag/components/role-name.js: -------------------------------------------------------------------------------- 1 | export default ({ elements, services, subscriptions }) => tagInstanceId => { 2 | 3 | const $roleName = elements.editableSpan('role-name') 4 | .addEventListener('change', () => { 5 | services.tags.changeTagRole(tagInstanceId, $roleName.textContent); 6 | }); 7 | 8 | subscriptions.tagInstances.onChange(tagInstanceId, 'roleName', roleName => { 9 | $roleName.textContent = roleName; 10 | }); 11 | 12 | return $roleName; 13 | 14 | }; 15 | -------------------------------------------------------------------------------- /src/modules/components/tag-list/tag/components/tag-image.js: -------------------------------------------------------------------------------- 1 | export default ({ ui }) => () => { 2 | 3 | return ui.el('div', 'tag-image'); 4 | 5 | }; 6 | 7 | /* FOOTNOTES 8 | 9 | Actual image is rendered using CSS background-image as a performance optimisation. 10 | 11 | */ 12 | -------------------------------------------------------------------------------- /src/modules/components/tag-list/tag/components/tag-name.js: -------------------------------------------------------------------------------- 1 | export default ({ elements, services, subscriptions }) => tagInstanceId => { 2 | 3 | const $tagName = elements.editableSpan('tag-name') 4 | .addEventListener('change', () => { 5 | services.tags.changeTagName(tagInstanceId, $tagName.textContent); 6 | }); 7 | 8 | subscriptions.tagInstances.onChange(tagInstanceId, 'tagName', tagName => { 9 | $tagName.textContent = tagName; 10 | }); 11 | 12 | return $tagName; 13 | 14 | }; 15 | -------------------------------------------------------------------------------- /src/modules/components/tag-list/tag/container.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, components, elements, services, subscriptions, config }) => tagInstanceId => { 2 | 3 | const $layout = elements.layout({ 4 | layout: config.tags.layout, 5 | components: components.tagList.tag.components, 6 | componentArgs: [tagInstanceId] 7 | }); 8 | 9 | const $tag = ui.el('div').append($layout); 10 | 11 | subscriptions.tagInstances.onChange(tagInstanceId, 'roleId', (roleId, { tagId, mode }) => { 12 | const isNilRole = services.roles.isNilRole(roleId); 13 | $tag.className = `tag tag${tagId} role${roleId} nil-role-${isNilRole} ${mode}`; 14 | }); 15 | 16 | return $tag; 17 | 18 | }; 19 | -------------------------------------------------------------------------------- /src/modules/components/tag-list/tag/index.js: -------------------------------------------------------------------------------- 1 | import components from './components/index.js'; 2 | import container from './container.js'; 3 | 4 | export default { 5 | components, 6 | container 7 | }; 8 | -------------------------------------------------------------------------------- /src/modules/components/tips/badges.js: -------------------------------------------------------------------------------- 1 | export default ({ ui }) => () => { 2 | 3 | return ui.el('div', { 4 | title: 'Blocked and other badges', 5 | innerHTML: ` 6 |

Create blocked and other badges in the same way as avatars.

7 | 8 | ` 9 | }); 10 | 11 | }; 12 | -------------------------------------------------------------------------------- /src/modules/components/tips/images.js: -------------------------------------------------------------------------------- 1 | export default ({ ui }) => () => { 2 | 3 | return ui.el('div', { 4 | title: 'Images', 5 | innerHTML: ` 6 |

7 | Have you ever joined a new team and struggled to remember everyone's names? 8 | You're not alone! Profile photos are the most effective way to identify people. 9 | Cartoon characters may be cute, but unless your team never changes, they're unhelpful. 10 |

11 |

12 | For a better looking result, make sure your images are square in shape. 13 |

` 14 | }); 15 | 16 | }; 17 | -------------------------------------------------------------------------------- /src/modules/components/tips/index.js: -------------------------------------------------------------------------------- 1 | import badges from './badges.js'; 2 | import images from './images.js'; 3 | import laminating from './laminating.js'; 4 | import multiples from './multiples.js'; 5 | import naming from './naming.js'; 6 | import roleShortcut from './role-shortcut.js'; 7 | 8 | export default { 9 | badges, 10 | images, 11 | laminating, 12 | multiples, 13 | naming, 14 | roleShortcut 15 | }; 16 | -------------------------------------------------------------------------------- /src/modules/components/tips/laminating.js: -------------------------------------------------------------------------------- 1 | export default ({ ui }) => () => { 2 | 3 | return ui.el('div', { 4 | title: 'Laminating', 5 | innerHTML: ` 6 |

7 | For a more durable result, cut out the tags before your laminate them. 8 | Keeping a small lip around the edges helps maintain adhesion. 9 |

` 10 | }); 11 | 12 | }; 13 | -------------------------------------------------------------------------------- /src/modules/components/tips/multiples.js: -------------------------------------------------------------------------------- 1 | export default ({ ui }) => () => { 2 | 3 | return ui.el('div', 'multiples', { 4 | title: 'Multiples', 5 | innerHTML: ` 6 |

7 | Only needing a single avatar per team member may be a sign that user stories are well defined, 8 | and that team members can take stories to completion without context switching. 9 | It also makes it easier to gauge who is focused on what and can serve as a natural WIP limit for the 10 | team. 11 | See INVEST for 12 | characteristics of good quality user stories. 13 |

14 | 15 |

16 | If multiples are really needed, consider one active, and one or more passive 17 | avatars for each team member. 18 | The active avatar indicates what a team member is focused on, while 19 | the passive avatar may be used to indicate the team member is across an activity which has 20 | become blocked, dependent on another team, or otherwise only requires ad hoc attention. 21 |

22 | 23 | 24 | ` 25 | }); 26 | 27 | }; 28 | -------------------------------------------------------------------------------- /src/modules/components/tips/naming.js: -------------------------------------------------------------------------------- 1 | export default ({ ui }) => () => { 2 | 3 | return ui.el('div', { 4 | title: 'Naming', 5 | innerHTML: ` 6 |

7 | Prefer short names and abbreviated roles. 8 | Less is more. Use just enough detail to identify people at a glance. 9 | Avoid full names and position titles if possible. 10 |

` 11 | }); 12 | 13 | }; 14 | -------------------------------------------------------------------------------- /src/modules/components/tips/role-shortcut.js: -------------------------------------------------------------------------------- 1 | export default ({ ui }) => () => { 2 | 3 | return ui.el('div', { 4 | title: 'Role shortcut', 5 | innerHTML: ` 6 |

Roles can be set quickly by appending +role to a name. This applies to:

7 | 8 | 13 | ` 14 | }); 15 | 16 | }; 17 | -------------------------------------------------------------------------------- /src/modules/core/gravatar/build-image-url.js: -------------------------------------------------------------------------------- 1 | export default ({ core, config }) => (email, defaultImage) => { 2 | 3 | const { domain, size } = config.gravatar; 4 | const emailHash = core.gravatar.hashEmail(email); 5 | return `${domain}/avatar/${emailHash}?r=g&s=${size}&d=${defaultImage}`; 6 | 7 | }; 8 | 9 | /* FOOTNOTES 10 | 11 | Gravatar image requests: 12 | https://en.gravatar.com/site/implement/images/ 13 | 14 | */ 15 | -------------------------------------------------------------------------------- /src/modules/core/gravatar/build-profile-url.js: -------------------------------------------------------------------------------- 1 | export default ({ config, core }) => email => { 2 | 3 | const { domain } = config.gravatar; 4 | const emailHash = core.gravatar.hashEmail(email); 5 | return `${domain}/${emailHash}.json`; 6 | 7 | }; 8 | 9 | /* FOOTNOTES 10 | 11 | Gravatar profile requests: 12 | https://en.gravatar.com/site/implement/profiles/ 13 | 14 | */ 15 | -------------------------------------------------------------------------------- /src/modules/core/gravatar/get-name-from-profile.js: -------------------------------------------------------------------------------- 1 | export default () => (profile, defaultName) => { 2 | 3 | return profile.name?.givenName || profile.displayName || defaultName; 4 | 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/core/gravatar/hash-email.js: -------------------------------------------------------------------------------- 1 | import md5 from 'blueimp-md5'; 2 | 3 | export default () => email => md5(email.trim().toLowerCase()); 4 | 5 | /* FOOTNOTES 6 | 7 | Gravatar email hashing: 8 | https://en.gravatar.com/site/implement/hash/ 9 | 10 | */ 11 | -------------------------------------------------------------------------------- /src/modules/core/gravatar/index.js: -------------------------------------------------------------------------------- 1 | import buildImageUrl from './build-image-url.js'; 2 | import buildProfileUrl from './build-profile-url.js'; 3 | import getNameFromProfile from './get-name-from-profile.js'; 4 | import hashEmail from './hash-email.js'; 5 | 6 | export default { 7 | buildImageUrl, 8 | buildProfileUrl, 9 | getNameFromProfile, 10 | hashEmail 11 | }; 12 | -------------------------------------------------------------------------------- /src/modules/core/index.js: -------------------------------------------------------------------------------- 1 | import gravatar from './gravatar/index.js'; 2 | import roles from './roles/index.js'; 3 | import tags from './tags/index.js'; 4 | 5 | export default { 6 | gravatar, 7 | roles, 8 | tags 9 | }; 10 | -------------------------------------------------------------------------------- /src/modules/core/roles/assign-color.js: -------------------------------------------------------------------------------- 1 | export default ({ core, config }) => randomNumber => roleData => { 2 | 3 | const presetColor = config.roles.presetColors[roleData.roleName]; 4 | const randomColor = core.roles.randomColor(randomNumber); 5 | const color = presetColor || roleData.color || randomColor; 6 | return { ...roleData, color }; 7 | 8 | }; 9 | -------------------------------------------------------------------------------- /src/modules/core/roles/build-role.js: -------------------------------------------------------------------------------- 1 | export default ({ core, util }) => (roleData, randomNumber) => { 2 | 3 | const transformRoleName = roleData => { 4 | const roleName = (roleData.roleName || '').trim().toUpperCase(); 5 | return { ...roleData, roleName }; 6 | }; 7 | 8 | return util.pipe( 9 | transformRoleName, 10 | core.roles.assignColor(randomNumber) 11 | )(roleData); 12 | 13 | }; 14 | -------------------------------------------------------------------------------- /src/modules/core/roles/index.js: -------------------------------------------------------------------------------- 1 | import assignColor from './assign-color.js'; 2 | import buildRole from './build-role.js'; 3 | import randomColor from './random-color.js'; 4 | 5 | export default { 6 | assignColor, 7 | buildRole, 8 | randomColor 9 | }; 10 | -------------------------------------------------------------------------------- /src/modules/core/roles/random-color.js: -------------------------------------------------------------------------------- 1 | export default () => randomNumber => { 2 | 3 | return '#' + ('00000' + ((randomNumber * (1 << 24)) | 0).toString(16)).slice(-6); 4 | 5 | }; 6 | 7 | /* FOOTNOTES 8 | 9 | - Based on: http://disq.us/p/d0itcl 10 | - Used to assign a color when a new role is added. 11 | 12 | */ 13 | -------------------------------------------------------------------------------- /src/modules/core/tags/build-tag.js: -------------------------------------------------------------------------------- 1 | export default ({ util }) => tagData => { 2 | 3 | const tagName = util.upperFirst((tagData.tagName || '').trim()); 4 | return { ...tagData, tagName, instances: [], active: [], passive: [] }; 5 | 6 | }; 7 | -------------------------------------------------------------------------------- /src/modules/core/tags/index.js: -------------------------------------------------------------------------------- 1 | import buildTag from './build-tag.js'; 2 | import parseEmailExpression from './parse-email-expression.js'; 3 | import parseFileExpression from './parse-file-expression.js'; 4 | import parseTagExpression from './parse-tag-expression.js'; 5 | import planTagInstanceAdjustment from './plan-tag-instance-adjustment.js'; 6 | import sortTagInstancesByTagThenMode from './sort-tag-instances-by-tag-then-mode.js'; 7 | import sortTagsByName from './sort-tags-by-name.js'; 8 | import sortTagsByRoleThenName from './sort-tags-by-role-then-name.js'; 9 | 10 | export default { 11 | buildTag, 12 | parseEmailExpression, 13 | parseFileExpression, 14 | parseTagExpression, 15 | planTagInstanceAdjustment, 16 | sortTagInstancesByTagThenMode, 17 | sortTagsByName, 18 | sortTagsByRoleThenName 19 | }; 20 | -------------------------------------------------------------------------------- /src/modules/core/tags/parse-email-expression.js: -------------------------------------------------------------------------------- 1 | export default ({ util }) => expression => { 2 | 3 | const indexOfAt = expression.indexOf('@'); 4 | const isEmail = indexOfAt > -1; 5 | const [username] = (isEmail ? expression.substr(0, indexOfAt) : expression).split('+'); 6 | const lastIndexOfPlus = expression.lastIndexOf('+'); 7 | const hasRole = lastIndexOfPlus > indexOfAt; 8 | const [emailOrUsername, roleName] = hasRole ? util.splitAt(expression, lastIndexOfPlus, 1) : [expression]; 9 | const email = isEmail ? emailOrUsername : ''; 10 | return { email, username, emailOrUsername, roleName }; 11 | 12 | }; 13 | 14 | /* FOOTNOTES 15 | 16 | Example of complex expression: 'foo+bar@gmail.com+dev' 17 | => { email: 'foo+bar@gmail.com', username: 'foo', emailOrUsername: 'foo+bar@gmail.com', roleName: 'dev' } 18 | 19 | */ 20 | -------------------------------------------------------------------------------- /src/modules/core/tags/parse-file-expression.js: -------------------------------------------------------------------------------- 1 | export default () => expression => { 2 | 3 | const [tagName, roleName] = expression 4 | .split('/') 5 | .pop() 6 | .match(/^(\d+)?(.+)/)[2] 7 | .split('.')[0] 8 | .split('+') 9 | .map(s => s.trim()); 10 | 11 | return { tagName, roleName }; 12 | 13 | }; 14 | 15 | /* FOOTNOTES 16 | 17 | Example of complex expression: '1 foo bar+dev.jpg' 18 | => { tagName: 'foo bar', roleName: 'dev' } 19 | 20 | Leading numbers are stripped to enable inserting tags in a preferred order. 21 | 22 | */ 23 | -------------------------------------------------------------------------------- /src/modules/core/tags/parse-tag-expression.js: -------------------------------------------------------------------------------- 1 | export default () => expression => { 2 | 3 | const [tagName, roleName] = expression.split('+').map(s => s.trim()); 4 | return { tagName, roleName }; 5 | 6 | }; 7 | 8 | /* FOOTNOTES 9 | 10 | Example of complex expression: 'foo bar+dev' 11 | => { tagName: 'foo bar', roleName: 'dev' } 12 | 13 | */ 14 | -------------------------------------------------------------------------------- /src/modules/core/tags/plan-tag-instance-adjustment.js: -------------------------------------------------------------------------------- 1 | export default () => (tags, modeCounts) => { 2 | 3 | return tags.flatMap(tag => { 4 | return Object.entries(modeCounts).flatMap(([mode, count]) => { 5 | return plan({ tag, mode, count }); 6 | }); 7 | }); 8 | 9 | }; 10 | 11 | const plan = ({ tag, mode, count }) => { 12 | const diff = count - tag[mode].length; 13 | 14 | const planInsert = () => { 15 | return { insert: Array(diff).fill({ tagId: tag.id, mode }) }; 16 | }; 17 | 18 | const planRemove = () => { 19 | return { remove: tag[mode].slice(diff) }; 20 | }; 21 | 22 | return diff ? (diff > 0 ? planInsert() : planRemove()) : {}; 23 | }; 24 | -------------------------------------------------------------------------------- /src/modules/core/tags/sort-tag-instances-by-tag-then-mode.js: -------------------------------------------------------------------------------- 1 | export default ({ config }) => (tags, getTagInstance) => { 2 | 3 | return tags.flatMap(tag => { 4 | return config.options.modes.flatMap(mode => { 5 | return tag[mode].map(tagInstanceId => getTagInstance(tagInstanceId)); 6 | }); 7 | }); 8 | 9 | }; 10 | -------------------------------------------------------------------------------- /src/modules/core/tags/sort-tags-by-name.js: -------------------------------------------------------------------------------- 1 | export default () => tags => { 2 | 3 | return tags.sort((a, b) => a.tagName.localeCompare(b.tagName)); 4 | 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/core/tags/sort-tags-by-role-then-name.js: -------------------------------------------------------------------------------- 1 | export default () => (tags, getRole) => { 2 | 3 | return tags.sort((a, b) => { 4 | const { roleName: roleNameA } = getRole(a.roleId); 5 | const { roleName: roleNameB } = getRole(b.roleId); 6 | const roleComparison = roleNameA.localeCompare(roleNameB); 7 | const nameComparison = a.tagName.localeCompare(b.tagName); 8 | return roleComparison || nameComparison; 9 | }); 10 | 11 | }; 12 | -------------------------------------------------------------------------------- /src/modules/diagnostics/dump-state.js: -------------------------------------------------------------------------------- 1 | export default ({ stores, util }) => () => { 2 | 3 | return util.mapValues(stores, store => store.list()); 4 | 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/diagnostics/index.js: -------------------------------------------------------------------------------- 1 | import dumpState from './dump-state.js'; 2 | 3 | export default { 4 | dumpState 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/elements/dropzone.js: -------------------------------------------------------------------------------- 1 | export default ({ ui }) => () => { 2 | 3 | const preventDefault = e => e.preventDefault(); 4 | 5 | return ui.el('div', 'dropzone') 6 | .addEventListener('dragenter', preventDefault) 7 | .addEventListener('dragover', preventDefault) 8 | .addEventListener('drop', preventDefault); 9 | 10 | }; 11 | -------------------------------------------------------------------------------- /src/modules/elements/editable-span.js: -------------------------------------------------------------------------------- 1 | export default ({ ui }) => className => { 2 | 3 | const dispatchChange = () => $span.dispatchEvent(ui.event('change')); 4 | 5 | const $span = ui.el('span', className) 6 | .addEventListener('blur', () => { 7 | dispatchChange(); 8 | }) 9 | .addEventListener('keydown', e => { 10 | if (e.code === 'Enter') { 11 | e.preventDefault(); 12 | dispatchChange(); 13 | } 14 | }); 15 | 16 | $span.setAttribute('contenteditable', true); 17 | 18 | return $span; 19 | }; 20 | 21 | /* FOOTNOTES 22 | 23 | - Content editable span preferred over text field for the ability to expand/contract while editing. 24 | - `e.preventDefault()` on enter key prevents cursor moving to next line. 25 | 26 | */ 27 | -------------------------------------------------------------------------------- /src/modules/elements/index.js: -------------------------------------------------------------------------------- 1 | import dropzone from './dropzone.js'; 2 | import editableSpan from './editable-span.js'; 3 | import label from './label.js'; 4 | import layout from './layout.js'; 5 | import modal from './modal.js'; 6 | import number from './number.js'; 7 | 8 | export default { 9 | dropzone, 10 | editableSpan, 11 | label, 12 | layout, 13 | modal, 14 | number 15 | }; 16 | -------------------------------------------------------------------------------- /src/modules/elements/label.js: -------------------------------------------------------------------------------- 1 | export default ({ ui }) => (...nodes) => { 2 | 3 | const transformedNodes = nodes.map(node => { 4 | return typeof node === 'string' ? ui.el('span', 'label', { innerHTML: node }) : node; 5 | }); 6 | 7 | return ui.el('label').append(...transformedNodes); 8 | 9 | }; 10 | 11 | /* FOOTNOTES 12 | 13 | Nesting the labelled control within the label avoids the need for `id` and `for`. 14 | 15 | */ 16 | -------------------------------------------------------------------------------- /src/modules/elements/layout.js: -------------------------------------------------------------------------------- 1 | export default ({ ui }) => ({ layout, components, componentArgs = [] }) => { 2 | 3 | const groups = layout.split('|').map(str => str.trim().split(' ')); 4 | 5 | const $$groups = groups.map(group => { 6 | const $$elements = group.map(name => components[name](...componentArgs)); 7 | return ui.el('span', 'group').append(...$$elements); 8 | }); 9 | 10 | return ui.el('div', 'layout').append(...$$groups); 11 | 12 | }; 13 | -------------------------------------------------------------------------------- /src/modules/elements/modal.js: -------------------------------------------------------------------------------- 1 | export default ({ ui }) => (args = {}) => { 2 | 3 | const defaults = { visible: false, onVisibilityChange: () => undefined }; 4 | const { title, content, actions, visible, onVisibilityChange } = { ...defaults, ...args }; 5 | 6 | const dismiss = () => { 7 | $overlay.dispatchEvent(ui.event('dismiss')); 8 | }; 9 | 10 | const $dismiss = ui.el('span', 'dismiss').addEventListener('click', dismiss); 11 | 12 | const $title = ui.el('div', 'modal-title').append(title, $dismiss); 13 | ui.toggleBoolClass($title, 'visible', Boolean(title)); 14 | 15 | const $content = ui.el('div', 'modal-content').append(content); 16 | 17 | const $actions = ui.el('div', 'modal-actions').append(actions); 18 | ui.toggleBoolClass($actions, 'visible', Boolean(actions)); 19 | 20 | const $prompt = ui.el('div', 'modal-prompt') 21 | .append($title, $content, $actions) 22 | .addEventListener('click', e => e.stopPropagation()); 23 | 24 | const $overlay = ui.el('div', 'modal-overlay') 25 | .append($prompt) 26 | .addEventListener('click', dismiss); 27 | 28 | const toggleVisibility = visible => { 29 | ui.toggleBoolClass($overlay, 'visible', visible); 30 | }; 31 | 32 | toggleVisibility(visible); 33 | onVisibilityChange(toggleVisibility, $overlay); 34 | 35 | return $overlay; 36 | 37 | }; 38 | -------------------------------------------------------------------------------- /src/modules/elements/number.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, util }) => ({ min, max, step }) => { 2 | 3 | const intValue = () => parseInt($number.value); 4 | 5 | const adjustValue = util.pipe( 6 | val => (isNaN(val) ? min : val), 7 | val => (val < min ? min : val), 8 | val => (val > max ? max : val) 9 | ); 10 | 11 | const onInput = () => { 12 | const val = intValue(); 13 | const adjustedVal = adjustValue(val); 14 | if (val === adjustedVal) $number.dispatchEvent(ui.event('change')); 15 | }; 16 | 17 | const onBlur = () => { 18 | $number.value = adjustValue(intValue()); 19 | onInput(); 20 | }; 21 | 22 | const $number = ui.el('input', { type: 'number', min, max, step }) 23 | .addEventListener('input', onInput) 24 | .addEventListener('blur', onBlur); 25 | 26 | return $number; 27 | 28 | }; 29 | 30 | /* FOOTNOTES 31 | 32 | About 33 | - Value can be any floating-point number, or empty. 34 | - Floating-point numbers avoided by setting `step` to an integer. 35 | - Automatically rejects non-numeric values (except empty). 36 | - Provides validation, but does not actually reject values outside min and max range. 37 | 38 | */ 39 | -------------------------------------------------------------------------------- /src/modules/index.js: -------------------------------------------------------------------------------- 1 | import components from './components/index.js'; 2 | import core from './core/index.js'; 3 | import diagnostics from './diagnostics/index.js'; 4 | import elements from './elements/index.js'; 5 | import io from './io/index.js'; 6 | import services from './services/index.js'; 7 | import startup from './startup/index.js'; 8 | import storage from './storage/index.js'; 9 | import stores from './stores/index.js'; 10 | import styles from './styles/index.js'; 11 | import subscriptions from './subscriptions/index.js'; 12 | import ui from './ui/index.js'; 13 | import util from './util/index.js'; 14 | import vendorComponents from './vendor-components/index.js'; 15 | 16 | export default { 17 | components, 18 | core, 19 | diagnostics, 20 | elements, 21 | io, 22 | services, 23 | startup, 24 | storage, 25 | stores, 26 | styles, 27 | subscriptions, 28 | ui, 29 | util, 30 | vendorComponents 31 | }; 32 | -------------------------------------------------------------------------------- /src/modules/io/index.js: -------------------------------------------------------------------------------- 1 | import setup from './setup.js'; 2 | 3 | export default { 4 | setup 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/io/setup.js: -------------------------------------------------------------------------------- 1 | import mixpanel from 'mixpanel-browser'; 2 | 3 | export default ({ window, config }) => () => { 4 | 5 | config.mixpanelToken && mixpanel.init(config.mixpanelToken, { debug: config.isTest }); 6 | 7 | return { 8 | mixpanel, 9 | date: () => new window.Date(), 10 | fetch: (...args) => window.fetch(...args), 11 | random: () => window.Math.random(), 12 | fileReader: () => new window.FileReader() 13 | }; 14 | 15 | }; 16 | -------------------------------------------------------------------------------- /src/modules/services/gravatar/change-fallback.js: -------------------------------------------------------------------------------- 1 | export default ({ stores }) => fallback => { 2 | 3 | stores.settings.update('gravatar', { fallback }); 4 | 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/services/gravatar/change-freetext.js: -------------------------------------------------------------------------------- 1 | export default ({ stores }) => freetext => { 2 | 3 | stores.settings.update('gravatar', { freetext }); 4 | 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/services/gravatar/fetch-image-async.js: -------------------------------------------------------------------------------- 1 | export default ({ core, io }) => async (emailOrUsername, defaultImage) => { 2 | 3 | const imageUrl = core.gravatar.buildImageUrl(emailOrUsername, defaultImage); 4 | const response = await io.fetch(imageUrl); 5 | if (!response.ok) throw new Error(`Unexpected Gravatar response status ${response.status}.`); 6 | return response.blob(); 7 | 8 | }; 9 | -------------------------------------------------------------------------------- /src/modules/services/gravatar/fetch-profile-async.js: -------------------------------------------------------------------------------- 1 | export default ({ core, io }) => async email => { 2 | 3 | if (!email) return {}; 4 | const url = core.gravatar.buildProfileUrl(email); 5 | const response = await io.fetch(url); 6 | if (response.status === 404) return {}; 7 | if (!response.ok) throw new Error(`Unexpected Gravatar response status ${response.status}.`); 8 | const data = await response.json(); 9 | const [profile] = data.entry; 10 | return profile; 11 | 12 | }; 13 | -------------------------------------------------------------------------------- /src/modules/services/gravatar/index.js: -------------------------------------------------------------------------------- 1 | import changeFallback from './change-fallback.js'; 2 | import changeFreetext from './change-freetext.js'; 3 | import fetchImageAsync from './fetch-image-async.js'; 4 | import fetchProfileAsync from './fetch-profile-async.js'; 5 | import status from './status.js'; 6 | 7 | export default { 8 | changeFallback, 9 | changeFreetext, 10 | fetchImageAsync, 11 | fetchProfileAsync, 12 | status 13 | }; 14 | -------------------------------------------------------------------------------- /src/modules/services/gravatar/status.js: -------------------------------------------------------------------------------- 1 | const STATUS = { 2 | ready: 'ready', 3 | working: 'working', 4 | error: 'error' 5 | }; 6 | 7 | export default ({ stores }) => { 8 | 9 | const is = Object.keys(STATUS).reduce((acc, status) => { 10 | const func = () => stores.settings.find('gravatar').status === status; 11 | return Object.assign(acc, { [status]: func }); 12 | }, {}); 13 | 14 | const to = { 15 | ready: () => { 16 | const { freetext } = stores.settings.find('gravatar'); 17 | stores.settings.update('gravatar', { 18 | status: STATUS.ready, 19 | freetext: is.error() ? freetext : '', 20 | errorMessage: '' 21 | }); 22 | }, 23 | working: () => { 24 | stores.settings.update('gravatar', { status: STATUS.working }); 25 | }, 26 | error: errorMessage => { 27 | stores.settings.update('gravatar', { status: STATUS.error, errorMessage }); 28 | } 29 | }; 30 | 31 | return { is, to }; 32 | 33 | }; 34 | -------------------------------------------------------------------------------- /src/modules/services/index.js: -------------------------------------------------------------------------------- 1 | import gravatar from './gravatar/index.js'; 2 | import roles from './roles/index.js'; 3 | import settings from './settings/index.js'; 4 | import tags from './tags/index.js'; 5 | 6 | export default { 7 | gravatar, 8 | roles, 9 | settings, 10 | tags 11 | }; 12 | -------------------------------------------------------------------------------- /src/modules/services/roles/change-role-color.js: -------------------------------------------------------------------------------- 1 | export default ({ stores }) => (roleId, color) => { 2 | 3 | stores.roles.update(roleId, { color }); 4 | 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/services/roles/change-role-name.js: -------------------------------------------------------------------------------- 1 | export default ({ core, stores }) => (roleId, roleName) => { 2 | 3 | const oldState = stores.roles.find(roleId); 4 | const newState = core.roles.buildRole({ ...oldState, roleName }); 5 | stores.roles.update(roleId, newState); 6 | 7 | }; 8 | -------------------------------------------------------------------------------- /src/modules/services/roles/find-or-insert-role-with-name.js: -------------------------------------------------------------------------------- 1 | export default ({ services, stores }) => (roleName = '') => { 2 | 3 | const existingRole = stores.roles.list().find(role => roleName.toUpperCase() === role.roleName.toUpperCase()); 4 | return existingRole ? existingRole.id : services.roles.insertRole({ roleName }); 5 | 6 | }; 7 | -------------------------------------------------------------------------------- /src/modules/services/roles/get-nil-role-id.js: -------------------------------------------------------------------------------- 1 | export default ({ stores }) => () => { 2 | 3 | return stores.settings.find('app').nilRoleId; 4 | 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/services/roles/get-role.js: -------------------------------------------------------------------------------- 1 | export default ({ stores }) => roleId => { 2 | 3 | return stores.roles.find(roleId); 4 | 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/services/roles/index.js: -------------------------------------------------------------------------------- 1 | import changeRoleColor from './change-role-color.js'; 2 | import changeRoleName from './change-role-name.js'; 3 | import findOrInsertRoleWithName from './find-or-insert-role-with-name.js'; 4 | import getNilRoleId from './get-nil-role-id.js'; 5 | import getRole from './get-role.js'; 6 | import insertRole from './insert-role.js'; 7 | import isNilRole from './is-nil-role.js'; 8 | import setupRolePropagation from './setup-role-propagation.js'; 9 | 10 | export default { 11 | changeRoleColor, 12 | changeRoleName, 13 | findOrInsertRoleWithName, 14 | getNilRoleId, 15 | getRole, 16 | insertRole, 17 | isNilRole, 18 | setupRolePropagation 19 | }; 20 | -------------------------------------------------------------------------------- /src/modules/services/roles/insert-role.js: -------------------------------------------------------------------------------- 1 | export default ({ core, services, subscriptions, stores, io }) => roleData => { 2 | 3 | const role = core.roles.buildRole(roleData, io.random()); 4 | 5 | return stores.roles.insert(role, roleId => { 6 | subscriptions.roles.onChange(roleId, 'roleName', services.roles.setupRolePropagation(roleId)); 7 | }); 8 | 9 | }; 10 | -------------------------------------------------------------------------------- /src/modules/services/roles/is-nil-role.js: -------------------------------------------------------------------------------- 1 | export default ({ services }) => roleId => { 2 | 3 | return roleId === services.roles.getNilRoleId(); 4 | 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/services/roles/setup-role-propagation.js: -------------------------------------------------------------------------------- 1 | export default ({ stores }) => roleId => () => { 2 | 3 | const { roleName } = stores.roles.find(roleId); 4 | const changes = { roleName }; 5 | const matchRole = tagInstance => tagInstance.roleId === roleId; 6 | const setRoleName = tagInstance => stores.tagInstances.update(tagInstance.id, changes); 7 | stores.tagInstances.list().filter(matchRole).forEach(setRoleName); 8 | 9 | }; 10 | -------------------------------------------------------------------------------- /src/modules/services/settings/change-modal.js: -------------------------------------------------------------------------------- 1 | export default ({ stores }) => modal => { 2 | 3 | stores.settings.update('app', { modal }); 4 | 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/services/settings/change-option.js: -------------------------------------------------------------------------------- 1 | export default ({ stores }) => (name, value) => { 2 | 3 | stores.settings.update('options', { [name]: value }); 4 | 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/services/settings/clear-modal.js: -------------------------------------------------------------------------------- 1 | export default ({ stores }) => () => { 2 | 3 | stores.settings.update('app', { modal: null }); 4 | 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/services/settings/get-gravatar.js: -------------------------------------------------------------------------------- 1 | export default ({ stores }) => () => { 2 | 3 | const gravatar = stores.settings.find('gravatar'); 4 | const emails = gravatar.freetext.split(/[\r\n,;]+/g).map(s => s.trim()).filter(s => s); 5 | return { emails, ...gravatar }; 6 | 7 | }; 8 | 9 | -------------------------------------------------------------------------------- /src/modules/services/settings/index.js: -------------------------------------------------------------------------------- 1 | import changeModal from './change-modal.js'; 2 | import changeOption from './change-option.js'; 3 | import clearModal from './clear-modal.js'; 4 | import getGravatar from './get-gravatar.js'; 5 | 6 | export default { 7 | changeModal, 8 | changeOption, 9 | clearModal, 10 | getGravatar 11 | }; 12 | -------------------------------------------------------------------------------- /src/modules/services/tags/adjust-tag-instance-counts.js: -------------------------------------------------------------------------------- 1 | export default ({ core, config, stores, services }) => () => { 2 | 3 | const tags = stores.tags.list(); 4 | 5 | const options = stores.settings.find('options'); 6 | const modeCounts = Object.fromEntries(config.options.modes.map(mode => [mode, options[mode]])); 7 | const plans = core.tags.planTagInstanceAdjustment(tags, modeCounts); 8 | 9 | plans.forEach(plan => { 10 | plan = { insert: [], remove: [], ...plan }; 11 | plan.insert.forEach(services.tags.insertTagInstance); 12 | plan.remove.forEach(services.tags.removeTagInstance); 13 | }); 14 | 15 | }; 16 | -------------------------------------------------------------------------------- /src/modules/services/tags/attach-image-async.js: -------------------------------------------------------------------------------- 1 | export default ({ stores, io }) => imageBlob => tagId => { 2 | 3 | return new Promise((resolve, reject) => { 4 | const reader = io.fileReader(); 5 | reader.readAsDataURL(imageBlob); 6 | reader.addEventListener('load', () => { 7 | const image = reader.result; 8 | stores.tags.update(tagId, { image }); 9 | resolve(); 10 | }); 11 | reader.addEventListener('error', () => { 12 | reject(reader.error); 13 | }); 14 | }); 15 | 16 | }; 17 | -------------------------------------------------------------------------------- /src/modules/services/tags/build-tag-instance.js: -------------------------------------------------------------------------------- 1 | export default ({ stores }) => tagInstanceData => { 2 | 3 | const { tagId, mode } = tagInstanceData; 4 | const { tagName, roleId } = stores.tags.find(tagId); 5 | const { roleName } = stores.roles.find(roleId); 6 | return { tagId, tagName, roleId, roleName, mode }; 7 | 8 | }; 9 | -------------------------------------------------------------------------------- /src/modules/services/tags/change-tag-name.js: -------------------------------------------------------------------------------- 1 | export default ({ core, services, stores }) => (tagInstanceId, expression) => { 2 | 3 | const { tagId } = services.tags.getTagInstance(tagInstanceId); 4 | const { tagName, roleName } = core.tags.parseTagExpression(expression); 5 | 6 | stores.tags.update(tagId, { tagName }); 7 | 8 | if (roleName) { 9 | const roleId = services.roles.findOrInsertRoleWithName(roleName); 10 | stores.tags.update(tagId, { roleId }); 11 | } 12 | 13 | }; 14 | -------------------------------------------------------------------------------- /src/modules/services/tags/change-tag-role.js: -------------------------------------------------------------------------------- 1 | export default ({ services, stores }) => (tagInstanceId, roleName) => { 2 | 3 | const roleId = services.roles.findOrInsertRoleWithName(roleName); 4 | const { tagId } = stores.tagInstances.find(tagInstanceId); 5 | stores.tags.update(tagId, { roleId }); 6 | 7 | }; 8 | -------------------------------------------------------------------------------- /src/modules/services/tags/get-tag-instance.js: -------------------------------------------------------------------------------- 1 | export default ({ stores }) => tagInstanceId => { 2 | 3 | return stores.tagInstances.find(tagInstanceId); 4 | 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/services/tags/index.js: -------------------------------------------------------------------------------- 1 | import adjustTagInstanceCounts from './adjust-tag-instance-counts.js'; 2 | import attachImageAsync from './attach-image-async.js'; 3 | import buildTagInstance from './build-tag-instance.js'; 4 | import changeTagName from './change-tag-name.js'; 5 | import changeTagRole from './change-tag-role.js'; 6 | import getTagInstance from './get-tag-instance.js'; 7 | import insertFileAsync from './insert-file-async.js'; 8 | import insertFileBatchAsync from './insert-file-batch-async.js'; 9 | import insertGravatarAsync from './insert-gravatar-async.js'; 10 | import insertGravatarBatchAsync from './insert-gravatar-batch-async.js'; 11 | import insertTag from './insert-tag.js'; 12 | import insertTagInstance from './insert-tag-instance.js'; 13 | import removeTagInstance from './remove-tag-instance.js'; 14 | import setupRolePropagation from './setup-role-propagation.js'; 15 | import setupTagPropagation from './setup-tag-propagation.js'; 16 | import sortTagInstances from './sort-tag-instances.js'; 17 | 18 | export default { 19 | adjustTagInstanceCounts, 20 | attachImageAsync, 21 | buildTagInstance, 22 | changeTagName, 23 | changeTagRole, 24 | getTagInstance, 25 | insertFileAsync, 26 | insertFileBatchAsync, 27 | insertGravatarAsync, 28 | insertGravatarBatchAsync, 29 | insertTag, 30 | insertTagInstance, 31 | removeTagInstance, 32 | setupRolePropagation, 33 | setupTagPropagation, 34 | sortTagInstances 35 | }; 36 | -------------------------------------------------------------------------------- /src/modules/services/tags/insert-file-async.js: -------------------------------------------------------------------------------- 1 | export default ({ core, services, util }) => file => { 2 | 3 | return util.pipe( 4 | core.tags.parseFileExpression, 5 | services.tags.insertTag, 6 | services.tags.attachImageAsync(file) 7 | )(file.name); 8 | 9 | }; 10 | -------------------------------------------------------------------------------- /src/modules/services/tags/insert-file-batch-async.js: -------------------------------------------------------------------------------- 1 | export default ({ services }) => files => { 2 | 3 | return Array.from(files).map(services.tags.insertFileAsync); 4 | 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/services/tags/insert-gravatar-async.js: -------------------------------------------------------------------------------- 1 | export default ({ core, services }) => (expression, defaultImage) => { 2 | 3 | const { email, username, emailOrUsername, roleName } = core.tags.parseEmailExpression(expression); 4 | 5 | const profilePromise = services.gravatar.fetchProfileAsync(email).then(profile => { 6 | const tagName = core.gravatar.getNameFromProfile(profile, username); 7 | return services.tags.insertTag({ tagName, roleName }); 8 | }); 9 | 10 | return services.gravatar.fetchImageAsync(emailOrUsername, defaultImage).then(async imageBlob => { 11 | const tagId = await profilePromise; 12 | return services.tags.attachImageAsync(imageBlob)(tagId); 13 | }); 14 | 15 | }; 16 | -------------------------------------------------------------------------------- /src/modules/services/tags/insert-gravatar-batch-async.js: -------------------------------------------------------------------------------- 1 | export default ({ services, config }) => async (emails, fallback) => { 2 | 3 | try { 4 | services.gravatar.status.to.working(); 5 | const insert = async email => { 6 | try { 7 | await services.tags.insertGravatarAsync(email, fallback); 8 | } 9 | catch (err) { 10 | // console.error(err); 11 | // todo: log 12 | } 13 | }; 14 | await Promise.all(emails.map(insert)); 15 | services.gravatar.status.to.ready(); 16 | services.settings.clearModal(); 17 | } catch (err) { 18 | services.gravatar.status.to.error(config.gravatar.errorMessage); 19 | throw err; // probably for logging 20 | } 21 | 22 | }; 23 | -------------------------------------------------------------------------------- /src/modules/services/tags/insert-tag-instance.js: -------------------------------------------------------------------------------- 1 | export default ({ services, stores }) => tagInstanceData => { 2 | 3 | const tagInstance = services.tags.buildTagInstance(tagInstanceData); 4 | 5 | return stores.tagInstances.insert(tagInstance, tagInstanceId => { 6 | const { tagId, mode } = tagInstance; 7 | const tag = stores.tags.find(tagId); 8 | 9 | stores.tags.update(tagId, { 10 | instances: tag.instances.concat(tagInstanceId), 11 | [mode]: tag[mode].concat(tagInstanceId) 12 | }); 13 | }); 14 | 15 | }; 16 | -------------------------------------------------------------------------------- /src/modules/services/tags/insert-tag.js: -------------------------------------------------------------------------------- 1 | export default ({ core, services, stores, subscriptions, util }) => tagData => { 2 | 3 | const assignRoleId = ({ roleName, ...tagData }) => { 4 | const roleId = tagData.roleId || services.roles.findOrInsertRoleWithName(roleName); 5 | return { roleId, ...tagData }; 6 | }; 7 | 8 | const insertTag = tag => { 9 | return stores.tags.insert(tag, tagId => { 10 | subscriptions.tags.onChange(tagId, 'tagName', services.tags.setupTagPropagation(tagId)); 11 | subscriptions.tags.onChange(tagId, 'roleId', services.tags.setupRolePropagation(tagId)); 12 | }); 13 | }; 14 | 15 | return util.pipe(assignRoleId, core.tags.buildTag, insertTag)(tagData || {}); 16 | 17 | }; 18 | -------------------------------------------------------------------------------- /src/modules/services/tags/remove-tag-instance.js: -------------------------------------------------------------------------------- 1 | export default ({ stores }) => tagInstanceId => { 2 | 3 | const { tagId, mode } = stores.tagInstances.find(tagInstanceId); 4 | const tag = stores.tags.find(tagId); 5 | 6 | const notThis = id => id !== tagInstanceId; 7 | 8 | stores.tags.update(tagId, { 9 | instances: tag.instances.filter(notThis), 10 | [mode]: tag[mode].filter(notThis) 11 | }); 12 | 13 | stores.tagInstances.remove(tagInstanceId); 14 | 15 | }; 16 | -------------------------------------------------------------------------------- /src/modules/services/tags/setup-role-propagation.js: -------------------------------------------------------------------------------- 1 | export default ({ stores }) => tagId => () => { 2 | 3 | const { roleId, instances } = stores.tags.find(tagId); 4 | const { roleName } = stores.roles.find(roleId); 5 | instances.forEach(id => stores.tagInstances.update(id, { roleId, roleName })); 6 | 7 | }; 8 | -------------------------------------------------------------------------------- /src/modules/services/tags/setup-tag-propagation.js: -------------------------------------------------------------------------------- 1 | export default ({ stores }) => tagId => () => { 2 | 3 | const { tagName, instances } = stores.tags.find(tagId); 4 | instances.forEach(id => stores.tagInstances.update(id, { tagName })); 5 | 6 | }; 7 | -------------------------------------------------------------------------------- /src/modules/services/tags/sort-tag-instances.js: -------------------------------------------------------------------------------- 1 | export default ({ core, stores }) => () => { 2 | 3 | const sorts = { 4 | orderAdded: stores.tags.list, 5 | roleThenName: () => core.tags.sortTagsByRoleThenName(stores.tags.list(), stores.roles.find), 6 | name: () => core.tags.sortTagsByName(stores.tags.list()) 7 | }; 8 | 9 | const { sort } = stores.settings.find('options'); 10 | const tags = sorts[sort](); 11 | return core.tags.sortTagInstancesByTagThenMode(tags, stores.tagInstances.find); 12 | 13 | }; 14 | -------------------------------------------------------------------------------- /src/modules/startup/create-handlers.js: -------------------------------------------------------------------------------- 1 | export default ({ services, subscriptions, util, config }) => () => { 2 | 3 | const adjustTagInstanceCounts = util.debounce( 4 | services.tags.adjustTagInstanceCounts, 5 | config.debounce.adjustTagInstanceCounts 6 | ); 7 | 8 | config.options.modes.forEach(mode => { 9 | subscriptions.settings.onChange('options', mode, adjustTagInstanceCounts); 10 | }); 11 | 12 | subscriptions.tags.onInsert(adjustTagInstanceCounts); 13 | 14 | }; 15 | -------------------------------------------------------------------------------- /src/modules/startup/create-style-manager.js: -------------------------------------------------------------------------------- 1 | export default ({ styles, subscriptions, ui, util }) => () => { 2 | 3 | const { tagImage, roleColor, ...otherStyles } = styles; 4 | const appendStyles = (...$$styles) => ui.appendToHead(...$$styles); 5 | appendStyles(...Object.values(otherStyles).map(style => style())); 6 | subscriptions.tags.onInsert(util.pipe(tagImage, appendStyles)); 7 | subscriptions.roles.onInsert(util.pipe(roleColor, appendStyles)); 8 | 9 | }; 10 | -------------------------------------------------------------------------------- /src/modules/startup/index.js: -------------------------------------------------------------------------------- 1 | import createHandlers from './create-handlers.js'; 2 | import createStyleManager from './create-style-manager.js'; 3 | import insertNilRole from './insert-nil-role.js'; 4 | import start from './start.js'; 5 | 6 | export default { 7 | createHandlers, 8 | createStyleManager, 9 | insertNilRole, 10 | start 11 | }; 12 | -------------------------------------------------------------------------------- /src/modules/startup/insert-nil-role.js: -------------------------------------------------------------------------------- 1 | export default ({ config, stores }) => () => { 2 | 3 | const nilRoleId = stores.roles.insert(config.roles.nilRole); 4 | stores.settings.update('app', { nilRoleId }); 5 | 6 | }; 7 | -------------------------------------------------------------------------------- /src/modules/startup/start.js: -------------------------------------------------------------------------------- 1 | export default ({ startup, components }) => () => { 2 | 3 | startup.insertNilRole(); 4 | startup.createHandlers(); 5 | startup.createStyleManager(); 6 | 7 | return components.app(); 8 | 9 | }; 10 | -------------------------------------------------------------------------------- /src/modules/storage/index.js: -------------------------------------------------------------------------------- 1 | import stateStore from './state-store.js'; 2 | 3 | export default { 4 | stateStore 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/storage/state-store.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events'; 2 | 3 | export default (defaults = {}) => { 4 | let nextId = 1; 5 | const state = new Map(); 6 | const funcs = new Map(); 7 | const collectionEmitter = new EventEmitter(); 8 | 9 | const manage = id => funcs.get(id) || { get: () => null }; 10 | const list = () => [...state.values()]; 11 | const find = id => manage(id).get(); 12 | const update = (id, changes) => manage(id).update(changes); 13 | 14 | const onChange = (id, field, listener) => manage(id).subscriptions.onChange(field, listener); 15 | const onChangeAny = (field, listener) => collectionEmitter.on(`change:${field}`, listener); 16 | const onInsert = listener => collectionEmitter.on('insert', listener); 17 | const onFirstInsert = listener => collectionEmitter.once('firstInsert', listener); 18 | const onBeforeRemove = listener => collectionEmitter.on('beforeRemove', listener); 19 | const subscriptions = { onChange, onChangeAny, onInsert, onFirstInsert, onBeforeRemove }; 20 | 21 | const insert = (data, callback) => { 22 | const id = data.id || nextId++; 23 | const item = { id, ...data }; 24 | const itemEmitter = new EventEmitter(); 25 | 26 | const get = () => ({ ...item }); 27 | 28 | const update = changes => { 29 | Object.entries(changes).forEach(([field, val]) => { 30 | if (item[field] === val) return; 31 | item[field] = val; 32 | const emit = emitter => emitter.emit(`change:${field}`, item[field], item); 33 | [itemEmitter, collectionEmitter].forEach(emit); 34 | }); 35 | }; 36 | 37 | const onChange = (field, listener) => { 38 | itemEmitter.on(`change:${field}`, listener); 39 | listener(item[field], item); 40 | }; 41 | 42 | const subscriptions = { onChange }; 43 | funcs.set(id, { get, update, subscriptions }); 44 | state.set(id, item); 45 | 46 | if (callback) callback(id); 47 | collectionEmitter.emit('firstInsert', id); 48 | collectionEmitter.emit('insert', id); 49 | return id; 50 | }; 51 | 52 | const remove = id => { 53 | collectionEmitter.emit('beforeRemove', id); 54 | funcs.delete(id); 55 | state.delete(id); 56 | }; 57 | 58 | Object.entries(defaults).map(([id, entry]) => ({ id, ...entry })).forEach(entry => insert(entry)); 59 | 60 | return { insert, remove, list, find, update, subscriptions }; 61 | 62 | }; 63 | 64 | -------------------------------------------------------------------------------- /src/modules/stores/index.js: -------------------------------------------------------------------------------- 1 | import setup from './setup.js'; 2 | 3 | export default { 4 | setup 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/stores/setup.js: -------------------------------------------------------------------------------- 1 | export default ({ storage, config }) => () => { 2 | 3 | return Object.fromEntries(config.storage.stores.map(name => { 4 | const defaults = config.storage.defaults[name]; 5 | const store = storage.stateStore(defaults); 6 | return [name, store]; 7 | })); 8 | 9 | }; 10 | -------------------------------------------------------------------------------- /src/modules/styles/index.js: -------------------------------------------------------------------------------- 1 | import roleColor from './role-color.js'; 2 | import tagImage from './tag-image.js'; 3 | import tagOutline from './tag-outline.js'; 4 | import tagShape from './tag-shape.js'; 5 | import tagSize from './tag-size.js'; 6 | import tagSpacing from './tag-spacing.js'; 7 | import vanillaPicker from './vanilla-picker.js'; 8 | 9 | export default { 10 | roleColor, 11 | tagImage, 12 | tagOutline, 13 | tagShape, 14 | tagSize, 15 | tagSpacing, 16 | vanillaPicker 17 | }; 18 | -------------------------------------------------------------------------------- /src/modules/styles/role-color.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, subscriptions }) => roleId => { 2 | 3 | const $style = ui.el('style'); 4 | 5 | subscriptions.roles.onChange(roleId, 'color', color => { 6 | $style.textContent = ` 7 | .role${roleId} .tag-image { border-color: ${color}; } 8 | .role${roleId} .role-name { background-color: ${color}; } 9 | `; 10 | }); 11 | 12 | return $style; 13 | 14 | }; 15 | -------------------------------------------------------------------------------- /src/modules/styles/tag-image.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, subscriptions }) => tagId => { 2 | 3 | const $style = ui.el('style'); 4 | 5 | subscriptions.tags.onChange(tagId, 'image', image => { 6 | $style.textContent = image ? `.tag${tagId} .tag-image { background-image: url(${image}); }` : ''; 7 | }); 8 | 9 | return $style; 10 | 11 | }; 12 | -------------------------------------------------------------------------------- /src/modules/styles/tag-outline.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, subscriptions }) => () => { 2 | 3 | const $style = ui.el('style'); 4 | 5 | subscriptions.settings.onChange('options', 'outline', outline => { 6 | $style.textContent = outline ? '' : '.tag { border-color: transparent; }'; 7 | }); 8 | 9 | return $style; 10 | 11 | }; 12 | -------------------------------------------------------------------------------- /src/modules/styles/tag-shape.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, subscriptions, config }) => () => { 2 | 3 | const $style = ui.el('style'); 4 | 5 | subscriptions.settings.onChange('options', 'shape', shape => { 6 | const borderRadius = config.options.shapeRadius[shape]; 7 | $style.textContent = `.tag-image { border-radius: ${borderRadius}%; }`; 8 | }); 9 | 10 | return $style; 11 | 12 | }; 13 | -------------------------------------------------------------------------------- /src/modules/styles/tag-size.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, subscriptions, config }) => () => { 2 | 3 | const $style = ui.el('style'); 4 | 5 | subscriptions.settings.onChange('options', 'size', size => { 6 | const width = size - (config.tags.imagePadding * 2); 7 | $style.textContent = ` 8 | .tag-list { grid-template-columns: repeat(auto-fill, ${size}px); } 9 | .tag-image { width: ${width}px; height: ${width}px; } 10 | `; 11 | }); 12 | 13 | return $style; 14 | 15 | }; 16 | 17 | /* FOOTNOTES 18 | 19 | Absolute size needed for `.tag-image` because the image is loaded as a 20 | CSS `background-image` of a
instead of an for performance reasons. 21 | 22 | */ 23 | -------------------------------------------------------------------------------- /src/modules/styles/tag-spacing.js: -------------------------------------------------------------------------------- 1 | export default ({ ui, subscriptions }) => () => { 2 | 3 | const $style = ui.el('style'); 4 | 5 | subscriptions.settings.onChange('options', 'spacing', spacing => { 6 | $style.textContent = `.tag-list { gap: ${spacing}px; }`; 7 | }); 8 | 9 | return $style; 10 | 11 | }; 12 | -------------------------------------------------------------------------------- /src/modules/styles/vanilla-picker.js: -------------------------------------------------------------------------------- 1 | import Picker from 'vanilla-picker'; 2 | 3 | export default ({ ui }) => () => { 4 | 5 | return ui.el('style', { textContent: Picker.css }); 6 | 7 | }; 8 | -------------------------------------------------------------------------------- /src/modules/subscriptions/index.js: -------------------------------------------------------------------------------- 1 | import setup from './setup.js'; 2 | 3 | export default { 4 | setup 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/subscriptions/setup.js: -------------------------------------------------------------------------------- 1 | export default ({ stores, util }) => () => { 2 | 3 | return util.mapValues(stores, store => store.subscriptions); 4 | 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/ui/append-to-head.js: -------------------------------------------------------------------------------- 1 | export default ({ window }) => (...el) => window.document.head.append(...el); 2 | -------------------------------------------------------------------------------- /src/modules/ui/el.js: -------------------------------------------------------------------------------- 1 | export default ({ window }) => (tagName, ...opts) => { 2 | 3 | const el = window.document.createElement(tagName); 4 | const props = opts.map(opt => (typeof opt === 'string' ? { className: opt } : opt)); 5 | const funcs = ['append', 'addEventListener'].map(name => { 6 | const orig = el[name].bind(el); 7 | const func = (...args) => { orig(...args); return el; }; 8 | return { [name]: func }; 9 | }); 10 | return Object.assign(el, ...props, ...funcs); 11 | 12 | }; 13 | -------------------------------------------------------------------------------- /src/modules/ui/event.js: -------------------------------------------------------------------------------- 1 | export default ({ window }) => (...args) => new window.Event(...args); 2 | -------------------------------------------------------------------------------- /src/modules/ui/index.js: -------------------------------------------------------------------------------- 1 | import appendToHead from './append-to-head.js'; 2 | import el from './el.js'; 3 | import event from './event.js'; 4 | import refocus from './refocus.js'; 5 | import toggleBoolClass from './toggle-bool-class.js'; 6 | 7 | export default { 8 | appendToHead, 9 | el, 10 | event, 11 | refocus, 12 | toggleBoolClass 13 | }; 14 | -------------------------------------------------------------------------------- /src/modules/ui/refocus.js: -------------------------------------------------------------------------------- 1 | export default ({ window }) => cb => { 2 | 3 | const el = window.document.activeElement; 4 | cb(); 5 | el.focus(); 6 | 7 | }; 8 | -------------------------------------------------------------------------------- /src/modules/ui/toggle-bool-class.js: -------------------------------------------------------------------------------- 1 | export default () => (el, className, bool) => { 2 | 3 | el.classList.remove(`${className}-${Boolean(!bool)}`); 4 | el.classList.add(`${className}-${Boolean(bool)}`); 5 | 6 | }; 7 | -------------------------------------------------------------------------------- /src/modules/util/debounce.js: -------------------------------------------------------------------------------- 1 | export default (func, wait) => { 2 | 3 | if (!wait) return func; 4 | 5 | let timeout = null; 6 | 7 | return (...args) => { 8 | const next = () => func(...args); 9 | clearTimeout(timeout); 10 | timeout = setTimeout(next, wait); 11 | }; 12 | 13 | 14 | }; 15 | -------------------------------------------------------------------------------- /src/modules/util/index.js: -------------------------------------------------------------------------------- 1 | import debounce from './debounce.js'; 2 | import mapValues from './map-values.js'; 3 | import pipe from './pipe.js'; 4 | import splitAt from './split-at.js'; 5 | import upperFirst from './upper-first.js'; 6 | 7 | export default { 8 | debounce, 9 | mapValues, 10 | pipe, 11 | splitAt, 12 | upperFirst 13 | }; 14 | -------------------------------------------------------------------------------- /src/modules/util/map-values.js: -------------------------------------------------------------------------------- 1 | export default (obj, mapper) => { 2 | 3 | return Object.entries(obj).reduce((acc, [key, val]) => { 4 | return Object.assign(acc, { [key]: mapper(val) }); 5 | }, {}); 6 | 7 | }; 8 | -------------------------------------------------------------------------------- /src/modules/util/pipe.js: -------------------------------------------------------------------------------- 1 | export default (...funcs) => initial => funcs.reduce((v, f) => f(v), initial); 2 | -------------------------------------------------------------------------------- /src/modules/util/split-at.js: -------------------------------------------------------------------------------- 1 | export default (str, index, offset) => { 2 | 3 | return [str.slice(0, index), str.slice(index + offset)]; 4 | 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/util/upper-first.js: -------------------------------------------------------------------------------- 1 | export default str => (str ? str[0].toUpperCase() + str.slice(1) : ''); 2 | -------------------------------------------------------------------------------- /src/modules/vendor-components/index.js: -------------------------------------------------------------------------------- 1 | import vanillaPicker from './vanilla-picker.js'; 2 | 3 | export default { 4 | vanillaPicker 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/vendor-components/vanilla-picker.js: -------------------------------------------------------------------------------- 1 | import Picker from 'vanilla-picker'; 2 | 3 | export default ({ window }) => ({ parent, color, onChange }) => { 4 | 5 | new Picker({ window, parent, color, onChange }); 6 | return parent; 7 | 8 | }; 9 | -------------------------------------------------------------------------------- /static/img/gravatar-fallbacks/identicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/static/img/gravatar-fallbacks/identicon.png -------------------------------------------------------------------------------- /static/img/gravatar-fallbacks/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | __modulename: 'gravatarFallbacks', 3 | identicon: require('./identicon'), 4 | monsterid: require('./monsterid'), 5 | mp: require('./mp'), 6 | retro: require('./retro'), 7 | robohash: require('./robohash'), 8 | wavatar: require('./wavatar') 9 | }; 10 | -------------------------------------------------------------------------------- /static/img/gravatar-fallbacks/monsterid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/static/img/gravatar-fallbacks/monsterid.png -------------------------------------------------------------------------------- /static/img/gravatar-fallbacks/mp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/static/img/gravatar-fallbacks/mp.png -------------------------------------------------------------------------------- /static/img/gravatar-fallbacks/retro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/static/img/gravatar-fallbacks/retro.png -------------------------------------------------------------------------------- /static/img/gravatar-fallbacks/robohash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/static/img/gravatar-fallbacks/robohash.png -------------------------------------------------------------------------------- /static/img/gravatar-fallbacks/wavatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/static/img/gravatar-fallbacks/wavatar.png -------------------------------------------------------------------------------- /static/img/tips/active-passive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/static/img/tips/active-passive.png -------------------------------------------------------------------------------- /static/img/tips/blocked-risk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/static/img/tips/blocked-risk.png -------------------------------------------------------------------------------- /static/img/welcome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattriley/agile-avatars/00bf90d44511ef92aa9edcfef278b2bfae0e029f/static/img/welcome.png -------------------------------------------------------------------------------- /task-vars: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export NODE_VERSION="20.2.0" 4 | 5 | export WEB_HOST_NAME="agileavatars.com" 6 | export INDEX_HTML_DESCRIPTION="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!" 7 | 8 | export BARREL_PATHS="$MODULES | ./testing/modules" 9 | export INDEXGEN_OPTIONS="--fullySpecified --type esm --filename index.js" 10 | export README_GEN="./readme-gen/app.js" 11 | export TEST_RUNNER="custom" 12 | 13 | export COV_BRANCHES="90" 14 | export COV_LINES="90" 15 | export COV_FUNCTIONS="90" 16 | export COV_STATEMENTS="90" 17 | 18 | # This project 19 | 20 | export MIXPANEL_TOKEN="2a4b8b61f947d51d88a2581a330d497e" 21 | -------------------------------------------------------------------------------- /testing/compose.js: -------------------------------------------------------------------------------- 1 | import composer from 'module-composer'; 2 | import modules from './modules/index.js'; 3 | 4 | export default ({ window, config }) => { 5 | 6 | const { compose } = composer(modules, { config }); 7 | return compose('helpers', { window }); 8 | 9 | }; 10 | -------------------------------------------------------------------------------- /testing/modules/helpers/assert-bool-class.js: -------------------------------------------------------------------------------- 1 | export default () => (assert, target, className) => bool => { 2 | 3 | assert.ok(target.classList.contains(`${className}-${bool}`)); 4 | assert.ok(!target.classList.contains(`${className}-${!bool}`)); 5 | 6 | }; 7 | -------------------------------------------------------------------------------- /testing/modules/helpers/dispatch-drop-files.js: -------------------------------------------------------------------------------- 1 | export default ({ window }) => (target, files) => { 2 | 3 | const event = new window.Event('drop'); 4 | Object.defineProperty(event, 'dataTransfer', { value: { files } }); 5 | target.dispatchEvent(event); 6 | 7 | }; 8 | -------------------------------------------------------------------------------- /testing/modules/helpers/dispatch-event.js: -------------------------------------------------------------------------------- 1 | export default ({ window }) => (eventName, target) => { 2 | 3 | target.dispatchEvent(new window.Event(eventName)); 4 | 5 | }; 6 | -------------------------------------------------------------------------------- /testing/modules/helpers/dispatch-keydown.js: -------------------------------------------------------------------------------- 1 | export default ({ window }) => (target, code) => { 2 | 3 | const e = new window.Event('Events'); 4 | e.initEvent('keydown', true, true); 5 | e.code = code; 6 | target.dispatchEvent(e); 7 | 8 | }; 9 | -------------------------------------------------------------------------------- /testing/modules/helpers/get-role.js: -------------------------------------------------------------------------------- 1 | export default ({ window }) => $roleCustomiser => { 2 | 3 | const $roleName = $roleCustomiser.querySelector('.role-name'); 4 | const getRoleName = () => $roleName.textContent; 5 | const getRoleStyle = () => window.getComputedStyle($roleName); 6 | return { getRoleName, getRoleStyle }; 7 | 8 | }; 9 | -------------------------------------------------------------------------------- /testing/modules/helpers/get-roles.js: -------------------------------------------------------------------------------- 1 | export default ({ helpers }) => $roleList => { 2 | 3 | const roleCustomisers = $roleList.querySelectorAll('.role-customiser'); 4 | return Array.from(roleCustomisers).map(helpers.getRole); 5 | 6 | }; 7 | -------------------------------------------------------------------------------- /testing/modules/helpers/get-tag-image-async.js: -------------------------------------------------------------------------------- 1 | export default ({ window }) => $tagImage => { 2 | 3 | return new Promise(resolve => { 4 | const intervalId = setInterval(() => { 5 | const style = window.getComputedStyle($tagImage); 6 | if (style.backgroundImage) { 7 | resolve(style.backgroundImage); 8 | clearInterval(intervalId); 9 | } 10 | }, 10); 11 | }); 12 | 13 | }; 14 | -------------------------------------------------------------------------------- /testing/modules/helpers/get-tags.js: -------------------------------------------------------------------------------- 1 | export default ({ window, helpers }) => $tagList => { 2 | 3 | return Array.from($tagList.querySelectorAll('.tag')).map($tag => { 4 | const $tagName = $tag.querySelector('.tag-name'); 5 | const $tagImage = $tag.querySelector('.tag-image'); 6 | const $roleName = $tag.querySelector('.role-name'); 7 | 8 | return { 9 | $tag, 10 | getTagName: () => $tagName.textContent, 11 | getRoleName: () => $roleName.textContent, 12 | getRoleStyle: () => window.getComputedStyle($roleName), 13 | getTagStyle: () => window.getComputedStyle($tag), 14 | getImageStyle: () => window.getComputedStyle($tagImage), 15 | getImage: () => helpers.getTagImageAsync($tagImage), 16 | setTagName: tagName => { 17 | $tagName.textContent = tagName; 18 | helpers.dispatchEvent('blur', $tagName); 19 | }, 20 | setRoleName: roleName => { 21 | $roleName.textContent = roleName; 22 | helpers.dispatchEvent('blur', $roleName); 23 | } 24 | }; 25 | }); 26 | 27 | }; 28 | -------------------------------------------------------------------------------- /testing/modules/helpers/index.js: -------------------------------------------------------------------------------- 1 | import assertBoolClass from './assert-bool-class.js'; 2 | import dispatchDropFiles from './dispatch-drop-files.js'; 3 | import dispatchEvent from './dispatch-event.js'; 4 | import dispatchKeydown from './dispatch-keydown.js'; 5 | import getRole from './get-role.js'; 6 | import getRoles from './get-roles.js'; 7 | import getTagImageAsync from './get-tag-image-async.js'; 8 | import getTags from './get-tags.js'; 9 | import onMutation from './on-mutation.js'; 10 | import onTagListMutation from './on-tag-list-mutation.js'; 11 | import test from './test.js'; 12 | 13 | export default { 14 | assertBoolClass, 15 | dispatchDropFiles, 16 | dispatchEvent, 17 | dispatchKeydown, 18 | getRole, 19 | getRoles, 20 | getTagImageAsync, 21 | getTags, 22 | onMutation, 23 | onTagListMutation, 24 | test 25 | }; 26 | -------------------------------------------------------------------------------- /testing/modules/helpers/on-mutation.js: -------------------------------------------------------------------------------- 1 | export default ({ window }) => ($target, trigger, ...callbacks) => { 2 | 3 | return new Promise(resolve => { 4 | const observer = new window.MutationObserver(async () => { 5 | const cb = callbacks.shift(); 6 | if (cb) await cb(); 7 | if (!callbacks.length) { 8 | observer.disconnect(); 9 | resolve(); 10 | } 11 | }); 12 | observer.observe($target, { childList: true, subtree: true }); 13 | trigger(); 14 | }); 15 | 16 | }; 17 | -------------------------------------------------------------------------------- /testing/modules/helpers/on-tag-list-mutation.js: -------------------------------------------------------------------------------- 1 | export default ({ helpers }) => ($tagList, trigger, ...callbacks) => { 2 | 3 | const decoratedCallbacks = callbacks.map(cb => { 4 | const decorated = () => cb(...helpers.getTags($tagList)); 5 | return cb ? decorated : undefined; 6 | }); 7 | 8 | return helpers.onMutation($tagList, trigger, ...decoratedCallbacks); 9 | 10 | }; 11 | -------------------------------------------------------------------------------- /testing/modules/helpers/test.js: -------------------------------------------------------------------------------- 1 | export default () => () => { 2 | 3 | console.log('foobar'); 4 | 5 | }; 6 | -------------------------------------------------------------------------------- /testing/modules/index.js: -------------------------------------------------------------------------------- 1 | import helpers from './helpers/index.js'; 2 | 3 | export default { 4 | helpers 5 | }; 6 | -------------------------------------------------------------------------------- /testing/test-config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | debounce: { 3 | adjustTagInstanceCounts: 0, 4 | sortTagList: 0 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /testing/test-runner.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | 3 | import JSDOM from 'jsdom'; 4 | import { run } from 'module-testrunner'; 5 | 6 | import { default as composeModules } from '../src/compose.js'; 7 | import { default as composeHelpers } from './compose.js'; 8 | import { default as defaultTestConfig } from './test-config.js'; 9 | 10 | const { window } = new JSDOM.JSDOM('', { url: 'https://localhost/' }); 11 | const { helpers } = composeHelpers({ window }); 12 | 13 | const compose = ({ overrides, config } = {}) => { 14 | window.document.getElementsByTagName('html')[0].innerHTML = ''; 15 | delete window.dataLayer; 16 | const options = { window, overrides, config: [defaultTestConfig, config] }; 17 | const { startup, composition } = composeModules(options); 18 | startup.start({ composition }); 19 | return composition; 20 | }; 21 | 22 | const context = { window, helpers }; 23 | const args = [{ compose }]; 24 | run({ context, args }); 25 | -------------------------------------------------------------------------------- /tests/components/app.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }) => ({ compose }) => { 2 | 3 | test('app renders', () => { 4 | const { components } = compose().modules; 5 | const $app = components.app(); 6 | assert($app); 7 | }); 8 | 9 | }; 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/components/choose-images.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }, { window, helpers }) => ({ compose }) => { 2 | 3 | test('multiple images chosen', () => { 4 | const { components } = compose().modules; 5 | const $tagList = components.tagList.container(); 6 | const $chooseImages = components.imageUploadOptions.chooseImages().querySelector('a'); 7 | 8 | const files = [ 9 | new window.File(['BYTES'], 'foo+bar.jpg', { type: 'image/jpg' }), 10 | new window.File(['BYTES'], 'baz+qux.jpg', { type: 'image/jpg' }) 11 | ]; 12 | 13 | $chooseImages.addEventListener('click', async e => { 14 | 15 | await helpers.onTagListMutation( 16 | $tagList, 17 | () => { 18 | // Work around not being able to create FileList or set files. 19 | const $fileInput = e.fileInput; 20 | Object.defineProperty($fileInput, 'files', { value: files }); 21 | helpers.dispatchEvent('change', $fileInput); 22 | }, 23 | (tag1, tag2) => { 24 | assert.deepEqual([tag1.getTagName(), tag1.getRoleName()], ['Foo', 'BAR']); 25 | assert.deepEqual([tag2.getTagName(), tag2.getRoleName()], ['Baz', 'QUX']); 26 | 27 | } 28 | ); 29 | 30 | }); 31 | 32 | helpers.dispatchEvent('click', $chooseImages); 33 | }); 34 | 35 | }; 36 | -------------------------------------------------------------------------------- /tests/components/drop-images.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }, { window, helpers }) => ({ compose }) => { 2 | 3 | test('multiple images dropped', async () => { 4 | const { components } = compose().modules; 5 | const $tagList = components.tagList.container(); 6 | const $dropzone = components.dropzone(); 7 | 8 | const files = [ 9 | new window.File(['BYTES'], 'foo+bar.jpg', { type: 'image/jpg' }), 10 | new window.File(['BYTES'], 'baz+qux.jpg', { type: 'image/jpg' }) 11 | ]; 12 | 13 | await helpers.onTagListMutation( 14 | $tagList, 15 | () => { 16 | const event = new window.Event('drop'); 17 | Object.defineProperty(event, 'dataTransfer', { value: { files } }); 18 | $dropzone.dispatchEvent(event); 19 | }, 20 | (tag1, tag2) => { 21 | assert.deepEqual([tag1.getTagName(), tag1.getRoleName()], ['Foo', 'BAR']); 22 | assert.deepEqual([tag2.getTagName(), tag2.getRoleName()], ['Baz', 'QUX']); 23 | 24 | } 25 | ); 26 | }); 27 | 28 | }; 29 | -------------------------------------------------------------------------------- /tests/components/gravatar.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }, { helpers }) => ({ compose }) => { 2 | 3 | test('launches gravatar', () => { 4 | const { components } = compose().modules; 5 | const $gravatarModal = components.modals.gravatar(); 6 | const assertVisible = helpers.assertBoolClass(assert, $gravatarModal, 'visible'); 7 | assertVisible(false); 8 | const $gravatarLink = components.imageUploadOptions.gravatar(); 9 | helpers.dispatchEvent('click', $gravatarLink); 10 | assertVisible(true); 11 | }); 12 | 13 | test('prevented from importing from gravatar with no input', () => { 14 | const { components } = compose().modules; 15 | const $gravatar = components.modals.gravatar(); 16 | const $importButton = $gravatar.querySelector('.import'); 17 | assert.equal($importButton.disabled, true); 18 | }); 19 | 20 | test('prevented from importing from gravatar with blank input', () => { 21 | const { components } = compose().modules; 22 | const $gravatar = components.modals.gravatar(); 23 | const $freetext = $gravatar.querySelector('.freetext'); 24 | $freetext.textContent = ' \n \n '; 25 | helpers.dispatchEvent('input', $freetext); 26 | const $importButton = $gravatar.querySelector('.import'); 27 | assert.equal($importButton.disabled, true); 28 | }); 29 | 30 | test('gravatar fallback changes', () => { 31 | const { components } = compose().modules; 32 | const $gravatar = components.modals.gravatar(); 33 | const $freetext = $gravatar.querySelector('.freetext'); 34 | const $monsterid = $gravatar.querySelector('[title=monsterid]'); 35 | $freetext.value = 'foo@bar.com'; 36 | helpers.dispatchEvent('input', $freetext); 37 | helpers.dispatchEvent('click', $monsterid); 38 | helpers.assertBoolClass(assert, $monsterid, 'selected', true); 39 | }); 40 | 41 | }; 42 | -------------------------------------------------------------------------------- /tests/components/gravatar/import-failure.fixme.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert, helpers }) => ({ compose }) => { 2 | 3 | test('import failure', async () => { 4 | 5 | const { components } = compose({ 6 | overrides: { 7 | services: { 8 | tags: { 9 | insertGravatarAsync: () => Promise.reject(new Error()) 10 | } 11 | } 12 | } 13 | }); 14 | 15 | 16 | const $gravatarModal = components.modals.gravatar(); 17 | const $freetextField = $gravatarModal.querySelector('.freetext'); 18 | const $importButton = $gravatarModal.querySelector('.import'); 19 | const $errorContainer = $gravatarModal.querySelector('.error'); 20 | 21 | const assertImportButtonVisible = helpers.assertBoolClass(assert, $importButton, 'visible'); 22 | const assertErrorContainerVisible = helpers.assertBoolClass(assert, $errorContainer, 'visible'); 23 | 24 | const freetext = 'foo@bar.com'; 25 | 26 | await helpers.onMutation( 27 | $gravatarModal, 28 | () => { 29 | // here 30 | 31 | $freetextField.value = freetext; 32 | helpers.dispatchEvent('input', $freetextField); 33 | assert.false($importButton.disabled); 34 | helpers.dispatchEvent('click', $importButton); 35 | // here 36 | }, 37 | () => { 38 | // not here 39 | // console.warn('$$$'); 40 | const $errorMessage = $errorContainer.querySelector('.error-message'); 41 | const $dismiss = $errorContainer.querySelector('.dismiss'); 42 | assertImportButtonVisible(false); 43 | assertErrorContainerVisible(true); 44 | assert.equal($errorMessage.textContent, 'An error occurred. Please check your connection and try again.'); 45 | helpers.dispatchEvent('click', $dismiss); 46 | }, 47 | () => { 48 | 49 | assertImportButtonVisible(true); 50 | assertErrorContainerVisible(false); 51 | assert.equal($freetextField.value, freetext); 52 | } 53 | ); 54 | 55 | }); 56 | 57 | }; 58 | -------------------------------------------------------------------------------- /tests/components/gravatar/import-success.fixme.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }) => ({ setup }) => { 2 | 3 | test('import success', async () => { 4 | const { compose, helpers, window } = setup(); 5 | 6 | const { components } = compose({ 7 | overrides: { 8 | services: { 9 | gravatar: { 10 | fetchProfileAsync: () => Promise.resolve({ displayName: 'foo' }), 11 | fetchImageAsync: () => Promise.resolve(new window.Blob(['BYTES'], { type: 'image/jpg' })) 12 | } 13 | } 14 | } 15 | }); 16 | 17 | const $gravatarModal = components.modals.gravatar(); 18 | const $freetextField = $gravatarModal.querySelector('.freetext'); 19 | const $importButton = $gravatarModal.querySelector('.import'); 20 | const $tagList = components.tagList.container(); 21 | 22 | const assertGravatarModalVisible = helpers.assertBoolClass(assert, $gravatarModal, 'visible'); 23 | 24 | $freetextField.value = 'foo@bar.com'; 25 | helpers.dispatchEvent('input', $freetextField); 26 | 27 | await helpers.onTagListMutation( 28 | $tagList, 29 | () => { 30 | helpers.dispatchEvent('click', $importButton); 31 | 32 | }, 33 | async tag1 => { 34 | assert.equal(tag1.getTagName(), 'Foo'); 35 | assert.equal(await tag1.getImage(), 'url(data:image/jpg;base64,QllURVM=)'); 36 | assertGravatarModalVisible(false); 37 | } 38 | ); 39 | }); 40 | 41 | }; 42 | -------------------------------------------------------------------------------- /tests/components/mode.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }, { helpers }) => ({ compose }) => { 2 | 3 | const testCase = (t, mode, adjustment) => { 4 | const defaultValue = 2; 5 | const targetCount = defaultValue + adjustment; 6 | 7 | const { components, services } = compose({ 8 | config: { 9 | storage: { 10 | defaults: { 11 | settings: { 12 | options: { active: 0, passive: 0, [mode]: defaultValue } 13 | } 14 | } 15 | } 16 | } 17 | }).modules; 18 | 19 | const subject = components.optionsBar.numberOption(mode); 20 | const $input = subject.querySelector('input'); 21 | const $tagList = components.tagList.container(); 22 | 23 | services.tags.insertTag(); 24 | 25 | { 26 | const modeCount = $tagList.querySelectorAll(`.${mode}`).length; 27 | assert.equal(modeCount, defaultValue); 28 | } 29 | 30 | $input.value = targetCount; 31 | helpers.dispatchEvent('input', $input); 32 | 33 | { 34 | const modeCount = $tagList.querySelectorAll(`.${mode}`).length; 35 | assert.equal(modeCount, targetCount); 36 | } 37 | 38 | 39 | }; 40 | 41 | test('active instances increased', () => { 42 | testCase(assert, 'active', 1); 43 | }); 44 | 45 | test('active instances decreased', () => { 46 | testCase(assert, 'active', -1); 47 | }); 48 | 49 | test('passive instances increased', () => { 50 | testCase(assert, 'passive', 1); 51 | }); 52 | 53 | test('passive instances decreased', () => { 54 | testCase(assert, 'passive', -1); 55 | }); 56 | 57 | }; 58 | -------------------------------------------------------------------------------- /tests/components/options-bar.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }, { helpers }) => ({ compose }) => { 2 | 3 | test('options bar not visible until first tag inserted', () => { 4 | const { components, services } = compose().modules; 5 | const $optionsBar = components.optionsBar.container(); 6 | const assertVisible = helpers.assertBoolClass(assert, $optionsBar, 'visible'); 7 | assertVisible(false); 8 | services.tags.insertTag(); 9 | assertVisible(true); 10 | }); 11 | 12 | }; 13 | -------------------------------------------------------------------------------- /tests/components/outline.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }, { helpers }) => ({ compose }) => { 2 | 3 | const setup = ({ outlineDefault }) => { 4 | const { components, services } = compose({ 5 | config: { 6 | storage: { 7 | defaults: { 8 | settings: { 9 | options: { outline: outlineDefault } 10 | } 11 | } 12 | } 13 | } 14 | }).modules; 15 | const $checkbox = components.optionsBar.options.outline().querySelector('input'); 16 | const $tagList = components.tagList.container(); 17 | services.tags.insertTag(); 18 | return { $tagList, $checkbox, helpers }; 19 | }; 20 | 21 | test('outline toggles on', () => { 22 | const { $tagList, $checkbox } = setup({ outlineDefault: false }); 23 | $checkbox.checked = true; 24 | helpers.dispatchEvent('change', $checkbox); 25 | const [tag1] = helpers.getTags($tagList); 26 | const outlineColor = tag1.getTagStyle().borderColor; 27 | assert.ok(outlineColor !== 'transparent'); 28 | 29 | }); 30 | 31 | test('outline toggles off', () => { 32 | const { $tagList, $checkbox } = setup({ outlineDefault: true }); 33 | $checkbox.checked = false; 34 | helpers.dispatchEvent('change', $checkbox); 35 | const [tag1] = helpers.getTags($tagList); 36 | const outlineColor = tag1.getTagStyle().borderColor; 37 | assert(outlineColor === 'transparent'); 38 | 39 | }); 40 | 41 | }; 42 | -------------------------------------------------------------------------------- /tests/components/role-color.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }, { helpers }) => ({ compose }) => { 2 | 3 | test('master role name reflects new color', () => { 4 | const { components, services } = compose().modules; 5 | 6 | const roleId = services.roles.insertRole({ roleName: 'foo' }); 7 | const $roleCustomiser = components.roleList.roleCustomiser.container(roleId); 8 | 9 | services.tags.insertTag({ roleId }); 10 | 11 | const $colorPickerTrigger = $roleCustomiser.querySelector('.color-picker'); 12 | helpers.dispatchEvent('click', $colorPickerTrigger); 13 | 14 | const $color = $colorPickerTrigger.querySelector('input'); 15 | $color.value = '#ffffffff'; 16 | helpers.dispatchEvent('input', $color); 17 | 18 | const role = helpers.getRole($roleCustomiser); 19 | assert.equal(role.getRoleStyle().backgroundColor, 'rgb(255, 255, 255)'); 20 | 21 | 22 | }); 23 | 24 | test('role color change propagates to tags', () => { 25 | const { components, services } = compose().modules; 26 | 27 | const roleId = services.roles.insertRole({ roleName: 'foo' }); 28 | const $roleCustomiser = components.roleList.roleCustomiser.container(roleId); 29 | 30 | const $tagList = components.tagList.container(); 31 | services.tags.insertTag({ roleId }); 32 | 33 | const $colorPickerTrigger = $roleCustomiser.querySelector('.color-picker'); 34 | helpers.dispatchEvent('click', $colorPickerTrigger); 35 | 36 | const $color = $colorPickerTrigger.querySelector('input'); 37 | $color.value = '#ffffffff'; 38 | helpers.dispatchEvent('input', $color); 39 | 40 | const [tag1] = helpers.getTags($tagList); 41 | const imageStyle = tag1.getImageStyle(); 42 | const roleStyle = tag1.getRoleStyle(); 43 | assert.equal(imageStyle.borderColor, '#ffffffff'); 44 | assert.equal(roleStyle.backgroundColor, 'rgb(255, 255, 255)'); 45 | 46 | }); 47 | 48 | }; 49 | -------------------------------------------------------------------------------- /tests/components/role-name.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }, { helpers }) => ({ compose }) => { 2 | 3 | test('role customiser name change propagates to tags', () => { 4 | const { components, services } = compose().modules; 5 | const $tagList = components.tagList.container(); 6 | const roleId = services.roles.insertRole({ roleName: 'foo' }); 7 | const $roleCustomiser = components.roleList.roleCustomiser.container(roleId); 8 | const $roleName = $roleCustomiser.querySelector('.role-name'); 9 | 10 | services.tags.insertTag({ roleId }); 11 | const tags = helpers.getTags($tagList); 12 | assert.equal(tags.length, 1); 13 | 14 | $roleName.textContent = 'bar'; 15 | helpers.dispatchEvent('blur', $roleName); 16 | const [tag1] = helpers.getTags($tagList); 17 | assert.equal(tag1.getRoleName(), 'BAR'); 18 | 19 | }); 20 | 21 | test('tag role name changes', () => { 22 | const { components, services } = compose().modules; 23 | const $tagList = components.tagList.container(); 24 | services.tags.insertTag({ roleName: 'foo' }); 25 | const [tag1] = helpers.getTags($tagList); 26 | assert.equal(tag1.getRoleName(), 'FOO'); 27 | tag1.setRoleName('bar'); 28 | assert.equal(tag1.getRoleName(), 'BAR'); 29 | 30 | }); 31 | 32 | test('new role inserted at end of list', () => { 33 | const { components, services } = compose().modules; 34 | const $roleList = components.roleList.container(); 35 | services.roles.insertRole({ roleName: 'foo' }); 36 | services.roles.insertRole({ roleName: 'bar' }); 37 | const [role1, role2] = helpers.getRoles($roleList); 38 | assert.equal(role1.getRoleName(), 'FOO'); 39 | assert.equal(role2.getRoleName(), 'BAR'); 40 | 41 | }); 42 | 43 | test('nil role is hidden', () => { 44 | const { components, services } = compose().modules; 45 | const nilRoleId = services.roles.getNilRoleId(); 46 | const $roleCustomiser = components.roleList.roleCustomiser.container(nilRoleId); 47 | assert($roleCustomiser.hidden); 48 | 49 | }); 50 | 51 | test('non-nil role is visible', async () => { 52 | const { components, services } = compose().modules; 53 | const roleId = await services.roles.insertRole({ roleName: 'foo' }); 54 | const $roleCustomiser = components.roleList.roleCustomiser.container(roleId); 55 | assert.ok(!$roleCustomiser.hidden); 56 | 57 | }); 58 | 59 | }; 60 | -------------------------------------------------------------------------------- /tests/components/shape.test.js: -------------------------------------------------------------------------------- 1 | // TODO: Explicit tests for toggling with Enter and Space keys. 2 | 3 | export default ({ test, assert }, { helpers }) => ({ compose }) => { 4 | 5 | const toggleTestCase = shapeName => { 6 | const { components } = compose().modules; 7 | const $shape = components.optionsBar.shapeOption(shapeName); 8 | const $anotherShape = components.optionsBar.shapeOption('foobar'); 9 | helpers.dispatchEvent('click', $anotherShape); 10 | const assertSelected = helpers.assertBoolClass(assert, $shape, 'selected'); 11 | assertSelected(false); 12 | helpers.dispatchEvent('click', $shape); 13 | assertSelected(true); 14 | }; 15 | 16 | const shapeTestCase = (t, shapeName) => { 17 | const { config: constants, modules } = compose(); 18 | const { components, services } = modules; 19 | const $shape = components.optionsBar.shapeOption(shapeName); 20 | const $tagList = components.tagList.container(); 21 | services.tags.insertTag(); 22 | helpers.dispatchKeydown($shape, 'Enter'); 23 | const [tag1] = helpers.getTags($tagList); 24 | const imageStyle = tag1.getImageStyle(); 25 | assert.equal(imageStyle.borderRadius, `${constants.options.shapeRadius[shapeName]}%`); 26 | 27 | }; 28 | 29 | test('square shape toggles on and off', () => { 30 | toggleTestCase('square'); 31 | }); 32 | 33 | test('square shape applied to tag', () => { 34 | shapeTestCase('square'); 35 | }); 36 | 37 | test('circle shape toggles on and off', () => { 38 | toggleTestCase('circle'); 39 | }); 40 | 41 | test('circle shape applied to tag', () => { 42 | shapeTestCase('circle'); 43 | }); 44 | 45 | // TODO: Remove duplication 46 | test('shape not selected on keypress other than Enter or Space', () => { 47 | const shapeName = 'circle'; 48 | const { components } = compose().modules; 49 | const $shape = components.optionsBar.shapeOption(shapeName); 50 | const $anotherShape = components.optionsBar.shapeOption('foobar'); 51 | helpers.dispatchEvent('click', $anotherShape); 52 | const assertSelected = helpers.assertBoolClass(assert, $shape, 'selected'); 53 | assertSelected(false); 54 | helpers.dispatchKeydown($shape, 'A'); 55 | assertSelected(false); 56 | }); 57 | 58 | }; 59 | -------------------------------------------------------------------------------- /tests/components/size.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }, { helpers, window }) => ({ compose }) => { 2 | 3 | const setup = ({ sizeDefault }) => { 4 | const { config: constants, modules } = compose({ 5 | config: { 6 | storage: { 7 | defaults: { 8 | settings: { 9 | options: { size: sizeDefault } 10 | } 11 | } 12 | } 13 | } 14 | }); 15 | const { components, services } = modules; 16 | const $sizeInput = components.optionsBar.options.size().querySelector('input'); 17 | const $tagList = components.tagList.container(); 18 | services.tags.insertTag(); 19 | return { $tagList, $sizeInput, constants }; 20 | }; 21 | 22 | const testCase = adjustment => { 23 | const sizeDefault = 150; 24 | const targetSize = sizeDefault + adjustment; 25 | const { $tagList, $sizeInput, constants } = setup({ sizeDefault }); 26 | $sizeInput.value = targetSize; 27 | helpers.dispatchEvent('input', $sizeInput); 28 | const [tag1] = helpers.getTags($tagList); 29 | const tagListStyle = window.getComputedStyle($tagList); 30 | assert.equal(tagListStyle.gridTemplateColumns, `repeat(auto-fill, ${targetSize}px)`); 31 | const sizeMinusPadding = targetSize - (constants.tags.imagePadding * 2); 32 | const imageStyle = tag1.getImageStyle(); 33 | assert.equal(imageStyle.width, `${sizeMinusPadding}px`); 34 | assert.equal(imageStyle.height, `${sizeMinusPadding}px`); 35 | 36 | }; 37 | 38 | test('tag size increases', () => { 39 | testCase(50); 40 | }); 41 | 42 | test('tag size decreases', () => { 43 | testCase(-50); 44 | }); 45 | 46 | }; 47 | -------------------------------------------------------------------------------- /tests/components/spacing.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }, { helpers, window }) => ({ compose }) => { 2 | 3 | const setup = ({ spacingDefault }) => { 4 | const { components } = compose({ 5 | config: { 6 | storage: { 7 | defaults: { 8 | settings: { 9 | options: { size: spacingDefault } 10 | } 11 | } 12 | } 13 | } 14 | }).modules; 15 | const $spacingInput = components.optionsBar.options.spacing().querySelector('input'); 16 | const $tagList = components.tagList.container(); 17 | return { $tagList, $spacingInput }; 18 | }; 19 | 20 | const testCase = adjustment => { 21 | const spacingDefault = 5; 22 | const { $tagList, $spacingInput } = setup({ spacingDefault }); 23 | const targetSpacing = spacingDefault + adjustment; 24 | $spacingInput.value = targetSpacing; 25 | helpers.dispatchEvent('input', $spacingInput); 26 | const tagListStyle = window.getComputedStyle($tagList); 27 | assert.equal(tagListStyle.gap, `${targetSpacing}px`); 28 | 29 | }; 30 | 31 | test('tag spacing increases', () => { 32 | testCase(2); 33 | }); 34 | 35 | test('tag spacing decreases', () => { 36 | testCase(-2); 37 | }); 38 | 39 | }; 40 | -------------------------------------------------------------------------------- /tests/components/tag-name.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }, { helpers }) => ({ compose }) => { 2 | 3 | test('tag name changes to blank', () => { 4 | const { components, services } = compose().modules; 5 | const $tagList = components.tagList.container(); 6 | services.tags.insertTag({ tagName: 'foo' }); 7 | const [tag1] = helpers.getTags($tagList); 8 | const { getTagName, setTagName } = tag1; 9 | assert.equal(getTagName(), 'Foo'); 10 | setTagName(''); 11 | assert.equal(getTagName(), ''); 12 | 13 | }); 14 | 15 | test('tag name changes to different name', () => { 16 | const { components, services } = compose().modules; 17 | const $tagList = components.tagList.container(); 18 | services.tags.insertTag({ tagName: 'foo' }); 19 | const [tag1] = helpers.getTags($tagList); 20 | const { getTagName, setTagName } = tag1; 21 | assert.equal(getTagName(), 'Foo'); 22 | setTagName('bar'); 23 | assert.equal(getTagName(), 'bar'); 24 | 25 | }); 26 | 27 | test('tag name and role changes given expression', () => { 28 | const { components, services } = compose().modules; 29 | const $tagList = components.tagList.container(); 30 | services.tags.insertTag(); 31 | const [tag1] = helpers.getTags($tagList); 32 | const { setTagName, getTagName, getRoleName } = tag1; 33 | setTagName('foo+bar'); 34 | assert.deepEqual([getTagName(), getRoleName()], ['foo', 'BAR']); 35 | 36 | }); 37 | 38 | test('tag name change propagates to all instances of tag', () => { 39 | const { components, services } = compose().modules; 40 | const $tagList = components.tagList.container(); 41 | services.tags.insertTag(); 42 | services.settings.changeOption('active', 2); 43 | 44 | { 45 | const tags = helpers.getTags($tagList); 46 | assert.equal(tags.length, 2); 47 | } 48 | 49 | { 50 | const [tag1] = helpers.getTags($tagList); 51 | const { setTagName } = tag1; 52 | setTagName('Foo'); 53 | } 54 | 55 | { 56 | const [tag1, tag2] = helpers.getTags($tagList); 57 | assert.equal(tag1.getTagName(), 'Foo'); 58 | assert.equal(tag2.getTagName(), 'Foo'); 59 | } 60 | 61 | 62 | }); 63 | 64 | }; 65 | -------------------------------------------------------------------------------- /tests/components/tips.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }, { helpers }) => ({ compose }) => { 2 | 3 | test('tips modal triggered by link in nav bar', () => { 4 | const { components } = compose().modules; 5 | const $tipsLink = components.header.titleBar().querySelector('.tips'); 6 | const $tipsModal = components.modals.tips('tips'); 7 | const assertVisible = helpers.assertBoolClass(assert, $tipsModal, 'visible'); 8 | assertVisible(false); 9 | helpers.dispatchEvent('click', $tipsLink); 10 | assertVisible(true); 11 | }); 12 | 13 | }; 14 | -------------------------------------------------------------------------------- /tests/components/welcome.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }, { helpers }) => ({ compose }) => { 2 | 3 | test('welcome modal is visible by default', () => { 4 | const { components } = compose().modules; 5 | const $welcomeModal = components.modals.welcome(); 6 | const assertVisible = helpers.assertBoolClass(assert, $welcomeModal, 'visible'); 7 | assertVisible(true); 8 | }); 9 | 10 | test('welcome modal dismissed by continue button', () => { 11 | const { components } = compose().modules; 12 | const $welcomeModal = components.modals.welcome(); 13 | const assertVisible = helpers.assertBoolClass(assert, $welcomeModal, 'visible'); 14 | const $dismiss = $welcomeModal.querySelector('button'); 15 | helpers.dispatchEvent('click', $dismiss); 16 | assertVisible(false); 17 | }); 18 | 19 | }; 20 | -------------------------------------------------------------------------------- /tests/core/gravatar/build-image-url.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }) => ({ compose }) => { 2 | 3 | test('builds a gravatar image url', () => { 4 | const { core } = compose().modules; 5 | const imageUrl = core.gravatar.buildImageUrl('foo@bar.com', 'monsterid'); 6 | assert.equal(imageUrl, 'https://secure.gravatar.com/avatar/f3ada405ce890b6f8204094deb12d8a8?r=g&s=600&d=monsterid'); 7 | 8 | }); 9 | 10 | }; 11 | -------------------------------------------------------------------------------- /tests/core/gravatar/build-profile-url.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }) => ({ compose }) => { 2 | 3 | test('builds a gravatar profile url', () => { 4 | const { core } = compose().modules; 5 | const profileUrl = core.gravatar.buildProfileUrl('foo@bar.com'); 6 | assert.equal(profileUrl, 'https://secure.gravatar.com/f3ada405ce890b6f8204094deb12d8a8.json'); 7 | 8 | }); 9 | 10 | }; 11 | -------------------------------------------------------------------------------- /tests/core/gravatar/get-name-from-profile.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }) => ({ compose }) => { 2 | 3 | const { core } = compose().modules; 4 | 5 | test('return given name when both given and display names present', () => { 6 | const profile = { name: { givenName: 'given' }, displayName: 'display' }; 7 | const name = core.gravatar.getNameFromProfile(profile, 'default'); 8 | assert.equal(name, 'given'); 9 | }); 10 | 11 | test('return display name when given name not present', () => { 12 | const profile = { name: {}, displayName: 'display' }; 13 | const name = core.gravatar.getNameFromProfile(profile, 'default'); 14 | assert.equal(name, 'display'); 15 | }); 16 | 17 | test('return default name when neither name or display name present', () => { 18 | const profile = {}; 19 | const name = core.gravatar.getNameFromProfile(profile, 'default'); 20 | assert.equal(name, 'default'); 21 | }); 22 | 23 | }; 24 | -------------------------------------------------------------------------------- /tests/core/roles/build-role.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }) => ({ compose }) => { 2 | 3 | const { core } = compose().modules; 4 | 5 | test('missing role name', () => { 6 | const role = core.roles.buildRole({}); 7 | assert.equal(role.roleName, ''); 8 | }); 9 | 10 | test('transforms role name', () => { 11 | const role = core.roles.buildRole({ roleName: ' foo ' }); 12 | assert.equal(role.roleName, 'FOO'); 13 | }); 14 | 15 | test('uses given color', () => { 16 | const role = core.roles.buildRole({ color: 'foo' }); 17 | assert.equal(role.color, 'foo'); 18 | }); 19 | 20 | test('uses preset color', () => { 21 | const role = core.roles.buildRole({ roleName: 'dev' }); 22 | assert.equal(role.color, '#48a56a'); 23 | }); 24 | 25 | test('uses random color', () => { 26 | const role = core.roles.buildRole({}, 0.3815); 27 | assert.equal(role.color, '#61a9fb'); 28 | }); 29 | 30 | }; 31 | -------------------------------------------------------------------------------- /tests/core/tags/parse-email-expression.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }) => ({ compose }) => { 2 | 3 | const { core } = compose().modules; 4 | 5 | test('email address', () => { 6 | const expression = 'foo+xzy@bar.com+dev'; 7 | const actual = core.tags.parseEmailExpression(expression); 8 | assert.deepEqual(actual, { 9 | email: 'foo+xzy@bar.com', 10 | username: 'foo', 11 | emailOrUsername: 'foo+xzy@bar.com', 12 | roleName: 'dev' 13 | }); 14 | }); 15 | 16 | test('username', () => { 17 | const expression = 'foo+dev'; 18 | const actual = core.tags.parseEmailExpression(expression); 19 | assert.deepEqual(actual, { 20 | email: '', 21 | username: 'foo', 22 | emailOrUsername: 'foo', 23 | roleName: 'dev' 24 | }); 25 | }); 26 | 27 | }; 28 | -------------------------------------------------------------------------------- /tests/core/tags/parse-file-expression.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }) => ({ compose }) => { 2 | 3 | const { core } = compose().modules; 4 | 5 | test('file name', () => { 6 | const expression = '1 foo bar+dev.jpg'; 7 | const actual = core.tags.parseFileExpression(expression); 8 | assert.deepEqual(actual, { 9 | tagName: 'foo bar', 10 | roleName: 'dev' 11 | }); 12 | }); 13 | 14 | }; 15 | -------------------------------------------------------------------------------- /tests/diagnostics/dump-state.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }) => ({ compose }) => { 2 | 3 | test('gets state', () => { 4 | const { diagnostics } = compose().modules; 5 | const state = diagnostics.dumpState(); 6 | assert.deepEqual(Object.keys(state), ['settings', 'roles', 'tags', 'tagInstances']); 7 | }); 8 | 9 | }; 10 | -------------------------------------------------------------------------------- /tests/elements/editable-span.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }, { helpers }) => ({ compose }) => { 2 | 3 | test('change invoked by enter key', async () => { 4 | await new Promise(resolve => { 5 | const { elements } = compose().modules; 6 | const $editableSpan = elements.editableSpan(); 7 | 8 | $editableSpan.addEventListener('change', () => { 9 | resolve(); 10 | 11 | }); 12 | 13 | helpers.dispatchKeydown($editableSpan, 'Enter'); 14 | }); 15 | }); 16 | 17 | test('change not invoked by key other than enter key', () => { 18 | const { elements } = compose().modules; 19 | const $editableSpan = elements.editableSpan(); 20 | 21 | $editableSpan.addEventListener('change', () => { 22 | assert.fail(); 23 | }); 24 | 25 | helpers.dispatchKeydown($editableSpan, 'A'); 26 | 27 | }); 28 | 29 | test('editing', () => { 30 | const { elements } = compose().modules; 31 | const $editableSpan = elements.editableSpan(); 32 | 33 | $editableSpan.addEventListener('change', () => { 34 | assert.equal($editableSpan.textContent, 'foo'); 35 | 36 | }); 37 | 38 | $editableSpan.textContent = 'foo'; 39 | helpers.dispatchEvent('blur', $editableSpan); 40 | }); 41 | 42 | }; 43 | -------------------------------------------------------------------------------- /tests/elements/number.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }, { helpers }) => ({ compose }) => { 2 | 3 | test('empty reverts to minimum on blur', () => { 4 | const { elements } = compose().modules; 5 | const $input = elements.number({ min: 1, max: 9, step: 1 }); 6 | $input.value = ''; 7 | helpers.dispatchEvent('blur', $input); 8 | assert.equal($input.value, '1'); 9 | }); 10 | 11 | test('empty remains empty on input', () => { 12 | const { elements } = compose().modules; 13 | const $input = elements.number({ min: 1, max: 9, step: 1 }); 14 | $input.value = ''; 15 | helpers.dispatchEvent('input', $input); 16 | assert.equal($input.value, ''); 17 | }); 18 | 19 | test('minimum is accepted', () => { 20 | const { elements } = compose().modules; 21 | const $input = elements.number({ min: 1, max: 9, step: 1 }); 22 | const newValue = 1; 23 | $input.value = newValue; 24 | helpers.dispatchEvent('input', $input); 25 | assert.equal($input.value, newValue.toString()); 26 | }); 27 | 28 | test('maxium is accepted', () => { 29 | const { elements } = compose().modules; 30 | const $input = elements.number({ min: 1, max: 9, step: 1 }); 31 | const newValue = 9; 32 | $input.value = newValue; 33 | helpers.dispatchEvent('input', $input); 34 | assert.equal($input.value, newValue.toString()); 35 | }); 36 | 37 | test('minimum enforced', () => { 38 | const { elements } = compose().modules; 39 | const $input = elements.number({ min: 1, max: 9, step: 1 }); 40 | $input.value = 0; 41 | helpers.dispatchEvent('blur', $input); 42 | assert.equal($input.value, '1'); 43 | }); 44 | 45 | test('maximum enforced', () => { 46 | const { elements } = compose().modules; 47 | const $input = elements.number({ min: 1, max: 9, step: 1 }); 48 | $input.value = 10; 49 | helpers.dispatchEvent('blur', $input); 50 | assert.equal($input.value, '9'); 51 | }); 52 | 53 | test('decimal ignored', () => { 54 | const { elements } = compose().modules; 55 | const $input = elements.number({ min: 1, max: 9, step: 1 }); 56 | $input.value = 2.5; 57 | helpers.dispatchEvent('blur', $input); 58 | assert.equal($input.value, '2'); 59 | }); 60 | 61 | }; 62 | -------------------------------------------------------------------------------- /tests/services/gravatar/fetch-image-async.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }) => ({ compose }) => { 2 | 3 | test('return image blob on successful response', async () => { 4 | const image = 'blob'; 5 | const fetch = url => { 6 | assert.equal(url, 'https://secure.gravatar.com/avatar/f3ada405ce890b6f8204094deb12d8a8?r=g&s=600&d=defaultimage'); 7 | return { ok: true, blob: () => image }; 8 | }; 9 | const io = { fetch }; 10 | const { services } = compose({ overrides: { io } }).modules; 11 | const actualImage = await services.gravatar.fetchImageAsync('foo@bar.com', 'defaultimage'); 12 | assert.equal(actualImage, image); 13 | }); 14 | 15 | test('throw on unexpected response status', async () => { 16 | const fetch = () => ({ ok: false, status: 500 }); 17 | const io = { fetch }; 18 | const { services } = compose({ overrides: { io } }).modules; 19 | try { 20 | await services.gravatar.fetchImageAsync('foo@bar.com', 'defaultimage'); 21 | assert.fail(); 22 | } catch (err) { 23 | assert.equal(err.message, 'Unexpected Gravatar response status 500.'); 24 | } 25 | }); 26 | 27 | }; 28 | -------------------------------------------------------------------------------- /tests/services/gravatar/fetch-profile-async.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test, assert }) => ({ compose }) => { 2 | 3 | test('return profile on successful response', async () => { 4 | const profile = { name: { givenName: 'given' }, displayName: 'display' }; 5 | const json = { entry: [profile] }; 6 | const fetch = url => { 7 | assert.equal(url, 'https://secure.gravatar.com/f3ada405ce890b6f8204094deb12d8a8.json'); 8 | return { ok: true, json: () => json }; 9 | }; 10 | const io = { fetch }; 11 | const { services } = compose({ overrides: { io } }).modules; 12 | const actualProfile = await services.gravatar.fetchProfileAsync('foo@bar.com'); 13 | assert.deepEqual(actualProfile, profile); 14 | }); 15 | 16 | test('return empty profile when email is null', async () => { 17 | const { services } = compose().modules; 18 | const profile = await services.gravatar.fetchProfileAsync(null); 19 | assert.deepEqual(profile, {}); 20 | }); 21 | 22 | test('return empty profile on 404 not found', async () => { 23 | const fetch = () => ({ status: 404 }); 24 | const io = { fetch }; 25 | const { services } = compose({ overrides: { io } }).modules; 26 | const profile = await services.gravatar.fetchProfileAsync('foo@bar.com'); 27 | assert.deepEqual(profile, {}); 28 | }); 29 | 30 | test('throw on unexpected response status', async () => { 31 | const fetch = () => ({ ok: false, status: 500 }); 32 | const io = { fetch }; 33 | const { services } = compose({ overrides: { io } }).modules; 34 | try { 35 | await services.gravatar.fetchProfileAsync('foo@bar.com'); 36 | assert.fail(); 37 | } catch (err) { 38 | assert.equal(err.message, 'Unexpected Gravatar response status 500.'); 39 | } 40 | }); 41 | 42 | }; 43 | -------------------------------------------------------------------------------- /tests/util/debounce.test.js: -------------------------------------------------------------------------------- 1 | export default ({ test }) => ({ compose }) => { 2 | 3 | test('debounce is zero', async () => { 4 | const { util } = compose().modules; 5 | await new Promise(resolve => { 6 | const foo = () => { 7 | resolve(); 8 | }; 9 | 10 | util.debounce(foo, 0)(); 11 | }); 12 | }); 13 | 14 | test('debounce is greater than zero', async () => { 15 | const { util } = compose().modules; 16 | await new Promise(resolve => { 17 | const foo = () => { 18 | resolve(); 19 | }; 20 | 21 | util.debounce(foo, 1)(); 22 | }); 23 | 24 | }); 25 | 26 | }; 27 | --------------------------------------------------------------------------------