├── .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 |
Create blocked and other badges in the same way as avatars.
7 |
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 |
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 | 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 |
--------------------------------------------------------------------------------