├── .babelrc ├── .gitignore ├── BUILDING.rst ├── CHANGES.rst ├── Dockerfile ├── README.rst ├── bootstrap.py ├── buildout.cfg ├── client ├── app.jsx ├── demo │ ├── baseapi.js │ ├── boardapi.js │ ├── default-states.json │ ├── indexeddb.js │ ├── intro.html │ ├── intro.jsx │ ├── sample.json │ ├── siteapi.js │ └── ui.jsx ├── emailpw │ └── ui.jsx ├── kb.html ├── model │ ├── apibase.js │ ├── board.js │ ├── boardapi.js │ ├── hastext.js │ ├── site.js │ └── siteapi.js ├── styles │ └── app.scss ├── tests │ ├── sample.json │ ├── testboard.js │ ├── testdemoboardapi.js │ └── testdemositeapi.js ├── ui │ ├── admin.jsx │ ├── app.jsx │ ├── board.jsx │ ├── dialog.jsx │ ├── dnd.jsx │ ├── frame.jsx │ ├── intro.html │ ├── intro.jsx │ ├── project.jsx │ ├── revealbutton.jsx │ ├── search.jsx │ ├── site.jsx │ ├── tasks.jsx │ ├── thebag.jsx │ ├── util.jsx │ └── who.jsx └── version.js ├── default-states.json ├── demo └── index.html ├── doc ├── Makefile ├── conf.py ├── contents.rst ├── index.rst ├── sample-board.png ├── try.rst └── valuenator.rst ├── docker ├── README.rst ├── build.cfg ├── start.cfg └── start.sh ├── express ├── README.rst ├── package.json └── server.js ├── karma.conf.js ├── package.json ├── postcss.config.js ├── raven.cfg ├── release.cfg ├── release.py ├── requirements.txt ├── rpm.cfg ├── rpm.spec ├── screenshot.png ├── server └── twotieredkanban │ ├── __init__.py │ ├── apibase.py │ ├── apiboard.py │ ├── apisite.py │ ├── apiutil.py │ ├── auth.text │ ├── board.py │ ├── default-states.json │ ├── emailpw-templates │ ├── base.html │ ├── emailpw.css │ ├── forgot.html │ ├── login.html │ ├── message.html │ ├── pw.html │ └── request.html │ ├── emailpw.py │ ├── favicon.ico │ ├── initializedb.py │ ├── interfaces.py │ ├── jwtauth.py │ ├── kb.html │ ├── server.py │ ├── ses.py │ ├── site.py │ ├── smtp.py │ ├── sql │ ├── __init__.py │ └── evolve1.sql │ └── tests │ ├── __init__.py │ ├── auth.py │ ├── sample-export.json │ ├── sample.json │ ├── sample.py │ ├── testapi.py │ ├── testboard.py │ ├── testemailpw.py │ ├── testsearch.py │ ├── testsite.py │ └── testtask.py ├── setup.py ├── stage-build ├── test.js ├── versions.cfg ├── webpack ├── webpack.config-test.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ['react', 'es2015'] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .installed.cfg 2 | bin 3 | develop-eggs 4 | dist 5 | *.egg-info 6 | *.pyc 7 | /key.cfg 8 | /parts 9 | /demo/static 10 | /static 11 | node_modules 12 | /attic 13 | /.tmp 14 | db.cfg 15 | valuenator.cfg 16 | .DS_Store 17 | /server.log 18 | /prod 19 | /doc/_build 20 | 21 | -------------------------------------------------------------------------------- /BUILDING.rst: -------------------------------------------------------------------------------- 1 | Building the app 2 | ================ 3 | 4 | There are currently 2 modes: 5 | 6 | demo 7 | Data are stored in the browser and can't be shared. 8 | 9 | server 10 | Data are stored on the server. 11 | 12 | Note that we're still punting on authentication and user management. 13 | 14 | Demo mode 15 | ========= 16 | 17 | Build:: 18 | 19 | npm install 20 | webpack --env.demo 21 | 22 | Run 23 | You have 2 options. 24 | 25 | If you're using Chrome: 26 | Open ``demo/index.html`` in your browser. If the browser 27 | generates a ``file::`` url by following the symbolic link, edit the URL 28 | to end in ``demo/index.html``. 29 | 30 | If you're using Firefox or Safari 31 | The browsers don't seem to work correctly with ``file://`` urls, 32 | so you'll want to host the demo files with a web server. You can 33 | use the express server. See express/README.rst 34 | 35 | We're mostly developing with Chrome. There are a number of 36 | problems using other browsers at this point. 37 | 38 | Server mode 39 | =========== 40 | 41 | To build, run the buildout. This will build the Python app, run npm, 42 | and webpack. 43 | 44 | Create a local ``kanban`` Postgres database. Alternatively, supply an 45 | alternate connection string when you run buildout:: 46 | 47 | bin/buildout database=postgresql://myuser@myhost/dbname 48 | 49 | To run:: 50 | 51 | bin/app fg 52 | 53 | (Use ``start`` rather than ``fg`` to run the server in the background.) 54 | 55 | Before accessing the Kanban for the first time, you will need to 56 | invite a (bootstrap) user:: 57 | 58 | bin/emailpw-bootstrap -bd -t "mysite" db.cfg localhost jim@jimfulton.info 'Jim Fulton' 59 | 60 | This will print an "email" message with a URL, which will look 61 | something like:: 62 | 63 | http://localhost:8000/auth/accept?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImppbUBqaW1mdWx0b24uaW5mbyJ9.iZRzDFb5-yKFQB0xJv1Pg5uicQG4hImOJiAe8ncJ9_o 64 | 65 | Opem the URL in your browser. That should present a page to set your 66 | password and then log in. 67 | 68 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | Change history 3 | ============== 4 | 5 | 0.15.3 (unreleased) 6 | ===================== 7 | 8 | Nothing changed yet 9 | 10 | 0.15.2 (2017-08-21) 11 | ===================== 12 | 13 | - Fixed: working task cards weren't green. 14 | 15 | 0.15.1 (2017-08-20) 16 | ===================== 17 | 18 | Accidental release with no real changes.c 19 | 20 | 0.15.0 (2017-08-20) 21 | ===================== 22 | 23 | - Added support for Docker-based deployment. 24 | 25 | 0.14.0 (2017-08-03) 26 | ===================== 27 | 28 | - Added Microsoft Edge 29 | 30 | - Added Firefox support. 31 | 32 | - Added Safari support. 33 | 34 | 0.13.1 (2017-07-11) 35 | ===================== 36 | 37 | - Only allow admins to rename boards. 38 | 39 | 0.13.0 (2017-07-11) 40 | ===================== 41 | 42 | - Added a help button. 43 | 44 | - Added a download button for board data. 45 | 46 | - Added a very basic feedback mechanism. 47 | 48 | 0.12.0 (2017-07-10) 49 | ===================== 50 | 51 | - Added a progress bar (for server version). 52 | 53 | - Added email verification and normalization for email+password login. 54 | 55 | - Added missing templates needed for email+password login. 56 | 57 | 0.11.0 (2017-07-09) 58 | ===================== 59 | 60 | - Refactored user on-boarding. Users must now request access rather 61 | than getting invites, to avoid sending unsolicited emails. 62 | 63 | - Added an "Administrative functions" screen with an initial tab for 64 | managing users. This lets you: 65 | 66 | - Assign or remove admin rights. 67 | 68 | - Approve access requests. 69 | 70 | 0.10.0 (2017-07-02) 71 | ===================== 72 | 73 | - Features and tasks can be deleted. 74 | 75 | - Style improvements 76 | 77 | - a favicon is provided. 78 | 79 | - Various bug fixes. 80 | 81 | 0.9.0 (2017-06-28) 82 | ===================== 83 | 84 | When adding or editing tasks, you can press the enter/return key in 85 | the title field to save the task. 86 | 87 | When adding tasks, if enter is pressed in the title field to save the 88 | new task, the dialog is redisplayed to add another task. When you're 89 | done adding tasks, you can press the escape key to cancel the form. 90 | 91 | When adding tasks, there's a new "Add and add another" button to add a 92 | task and then add another one. This is eqiovalent to pressing "enter" 93 | in the title fields. 94 | 95 | When adding another task (by pressing enter in the title field or 96 | clicking "Add and add another"), a message pops up from the bottom of 97 | the screen confirming that the add was done. 98 | 99 | When adding or entering tasks, if the title ends with a number in 100 | square braces, it will be used as the size when the task is saved. 101 | 102 | Pressing the escape key in dialogs is equivalent to clicking the 103 | cancel button. 104 | 105 | In dialogs with text input, automatically give the first text input focus. 106 | 107 | Fixed: pressing the escape key didn't cancel dialogs. 108 | 109 | 0.8.0 (2017-06-25) 110 | ===================== 111 | 112 | Boards can now be renamed. 113 | 114 | When dragging an empty feature to exploded state, add an empty task. 115 | 116 | When visiting invalid routes (e.g. incorrent board name), the user is 117 | is redirected to the welcome screen. 118 | 119 | Added tooltips for several icon buttons. 120 | 121 | Fixed: "Unassigned" was an option for user switching in demo. 122 | 123 | Fixed: Archived-feature didn't qualify search by board. 124 | 125 | 0.7.0 (2017-06-23) 126 | ===================== 127 | 128 | Implemented "The Bag" to hold old (generally finished) features. 129 | 130 | 0.6.0 (2017-06-18) 131 | ===================== 132 | 133 | Improved the display of features and tasks. Both now always have 134 | reveal functionality, which, allows descriptions to be viewed without 135 | editing. For projects, this makes the (unexpanded) development view a 136 | little cleaner. 137 | 138 | 0.5.1 (2017-06-17) 139 | ===================== 140 | 141 | - Include release information in sentry data and show the client 142 | releas in the nav drawer. 143 | 144 | 0.5.0 (2017-06-17) 145 | ===================== 146 | 147 | Intitial numbered release after major refactoring to use React, ES6, 148 | Newt DB, and other modernizations, like webpack. 149 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | from ubuntu:17.04 2 | 3 | run apt-get clean && apt-get update && apt-get install -y locales; \ 4 | locale-gen "en_US.UTF-8" ; \ 5 | apt-get install -y \ 6 | build-essential python3-dev python3-venv python-virtualenv npm \ 7 | libevent-dev zlib1g-dev libpq-dev libssh-dev libffi-dev libbz2-dev 8 | 9 | run python3 -m venv /env 10 | 11 | copy requirements.txt / 12 | run /env/bin/pip install -r /requirements.txt 13 | 14 | copy package.json /app/package.json 15 | run npm set progress=false; \ 16 | ln -s /usr/bin/nodejs /usr/bin/node; \ 17 | cd /app; npm install 18 | 19 | copy . /app/ 20 | run cd /app ; /env/bin/buildout -oc docker/build.cfg 21 | 22 | expose 8000 23 | label maintainer="jim@jimfulton.info" 24 | cmd /bin/sh /app/docker/start.sh 25 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Feature-flow/Valuenator 2 | ======================= 3 | 4 | `Feature-flow `_ is an agile 5 | practice for organizing work around units of value rather than time. 6 | 7 | The Valuenator application, implemented here, provides a tool you can 8 | use to implement the practice. 9 | 10 | .. image:: screenshot.png 11 | 12 | Checkout the `documentation `_ to 13 | learn more. 14 | 15 | Status and changes 16 | ================== 17 | 18 | The software is currently in an alpha state. You can try out a `demo 19 | version `_ that stores data 20 | persistently in your browser. It currently requires Chrome. We'll be 21 | adding support for other browsers soon. 22 | 23 | We'll also be announcing a beta for an on-line version soon. 24 | -------------------------------------------------------------------------------- /bootstrap.py: -------------------------------------------------------------------------------- 1 | ############################################################################## 2 | # 3 | # Copyright (c) 2006 Zope Foundation and Contributors. 4 | # All Rights Reserved. 5 | # 6 | # This software is subject to the provisions of the Zope Public License, 7 | # Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. 8 | # THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED 9 | # WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 10 | # WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS 11 | # FOR A PARTICULAR PURPOSE. 12 | # 13 | ############################################################################## 14 | """Bootstrap a buildout-based project 15 | 16 | Simply run this script in a directory containing a buildout.cfg. 17 | The script accepts buildout command-line options, so you can 18 | use the -c option to specify an alternate configuration file. 19 | """ 20 | 21 | import os 22 | import shutil 23 | import sys 24 | import tempfile 25 | 26 | from optparse import OptionParser 27 | 28 | __version__ = '2015-07-01' 29 | # See zc.buildout's changelog if this version is up to date. 30 | 31 | tmpeggs = tempfile.mkdtemp(prefix='bootstrap-') 32 | 33 | usage = '''\ 34 | [DESIRED PYTHON FOR BUILDOUT] bootstrap.py [options] 35 | 36 | Bootstraps a buildout-based project. 37 | 38 | Simply run this script in a directory containing a buildout.cfg, using the 39 | Python that you want bin/buildout to use. 40 | 41 | Note that by using --find-links to point to local resources, you can keep 42 | this script from going over the network. 43 | ''' 44 | 45 | parser = OptionParser(usage=usage) 46 | parser.add_option("--version", 47 | action="store_true", default=False, 48 | help=("Return bootstrap.py version.")) 49 | parser.add_option("-t", "--accept-buildout-test-releases", 50 | dest='accept_buildout_test_releases', 51 | action="store_true", default=False, 52 | help=("Normally, if you do not specify a --version, the " 53 | "bootstrap script and buildout gets the newest " 54 | "*final* versions of zc.buildout and its recipes and " 55 | "extensions for you. If you use this flag, " 56 | "bootstrap and buildout will get the newest releases " 57 | "even if they are alphas or betas.")) 58 | parser.add_option("-c", "--config-file", 59 | help=("Specify the path to the buildout configuration " 60 | "file to be used.")) 61 | parser.add_option("-f", "--find-links", 62 | help=("Specify a URL to search for buildout releases")) 63 | parser.add_option("--allow-site-packages", 64 | action="store_true", default=False, 65 | help=("Let bootstrap.py use existing site packages")) 66 | parser.add_option("--buildout-version", 67 | help="Use a specific zc.buildout version") 68 | parser.add_option("--setuptools-version", 69 | help="Use a specific setuptools version") 70 | parser.add_option("--setuptools-to-dir", 71 | help=("Allow for re-use of existing directory of " 72 | "setuptools versions")) 73 | 74 | options, args = parser.parse_args() 75 | if options.version: 76 | print("bootstrap.py version %s" % __version__) 77 | sys.exit(0) 78 | 79 | 80 | ###################################################################### 81 | # load/install setuptools 82 | 83 | try: 84 | from urllib.request import urlopen 85 | except ImportError: 86 | from urllib2 import urlopen 87 | 88 | ez = {} 89 | if os.path.exists('ez_setup.py'): 90 | exec(open('ez_setup.py').read(), ez) 91 | else: 92 | exec(urlopen('https://bootstrap.pypa.io/ez_setup.py').read(), ez) 93 | 94 | if not options.allow_site_packages: 95 | # ez_setup imports site, which adds site packages 96 | # this will remove them from the path to ensure that incompatible versions 97 | # of setuptools are not in the path 98 | import site 99 | # inside a virtualenv, there is no 'getsitepackages'. 100 | # We can't remove these reliably 101 | if hasattr(site, 'getsitepackages'): 102 | for sitepackage_path in site.getsitepackages(): 103 | # Strip all site-packages directories from sys.path that 104 | # are not sys.prefix; this is because on Windows 105 | # sys.prefix is a site-package directory. 106 | if sitepackage_path != sys.prefix: 107 | sys.path[:] = [x for x in sys.path 108 | if sitepackage_path not in x] 109 | 110 | setup_args = dict(to_dir=tmpeggs, download_delay=0) 111 | 112 | if options.setuptools_version is not None: 113 | setup_args['version'] = options.setuptools_version 114 | if options.setuptools_to_dir is not None: 115 | setup_args['to_dir'] = options.setuptools_to_dir 116 | 117 | ez['use_setuptools'](**setup_args) 118 | import setuptools 119 | import pkg_resources 120 | 121 | # This does not (always?) update the default working set. We will 122 | # do it. 123 | for path in sys.path: 124 | if path not in pkg_resources.working_set.entries: 125 | pkg_resources.working_set.add_entry(path) 126 | 127 | ###################################################################### 128 | # Install buildout 129 | 130 | ws = pkg_resources.working_set 131 | 132 | setuptools_path = ws.find( 133 | pkg_resources.Requirement.parse('setuptools')).location 134 | 135 | # Fix sys.path here as easy_install.pth added before PYTHONPATH 136 | cmd = [sys.executable, '-c', 137 | 'import sys; sys.path[0:0] = [%r]; ' % setuptools_path + 138 | 'from setuptools.command.easy_install import main; main()', 139 | '-mZqNxd', tmpeggs] 140 | 141 | find_links = os.environ.get( 142 | 'bootstrap-testing-find-links', 143 | options.find_links or 144 | ('http://downloads.buildout.org/' 145 | if options.accept_buildout_test_releases else None) 146 | ) 147 | if find_links: 148 | cmd.extend(['-f', find_links]) 149 | 150 | requirement = 'zc.buildout' 151 | version = options.buildout_version 152 | if version is None and not options.accept_buildout_test_releases: 153 | # Figure out the most recent final version of zc.buildout. 154 | import setuptools.package_index 155 | _final_parts = '*final-', '*final' 156 | 157 | def _final_version(parsed_version): 158 | try: 159 | return not parsed_version.is_prerelease 160 | except AttributeError: 161 | # Older setuptools 162 | for part in parsed_version: 163 | if (part[:1] == '*') and (part not in _final_parts): 164 | return False 165 | return True 166 | 167 | index = setuptools.package_index.PackageIndex( 168 | search_path=[setuptools_path]) 169 | if find_links: 170 | index.add_find_links((find_links,)) 171 | req = pkg_resources.Requirement.parse(requirement) 172 | if index.obtain(req) is not None: 173 | best = [] 174 | bestv = None 175 | for dist in index[req.project_name]: 176 | distv = dist.parsed_version 177 | if _final_version(distv): 178 | if bestv is None or distv > bestv: 179 | best = [dist] 180 | bestv = distv 181 | elif distv == bestv: 182 | best.append(dist) 183 | if best: 184 | best.sort() 185 | version = best[-1].version 186 | if version: 187 | requirement = '=='.join((requirement, version)) 188 | cmd.append(requirement) 189 | 190 | import subprocess 191 | if subprocess.call(cmd) != 0: 192 | raise Exception( 193 | "Failed to execute command:\n%s" % repr(cmd)[1:-1]) 194 | 195 | ###################################################################### 196 | # Import and run buildout 197 | 198 | ws.add_entry(tmpeggs) 199 | ws.require(requirement) 200 | import zc.buildout.buildout 201 | 202 | if not [a for a in args if '=' not in a]: 203 | args.append('bootstrap') 204 | 205 | # if -c was provided, we push it back into args for buildout' main function 206 | if options.config_file is not None: 207 | args[0:0] = ['-c', options.config_file] 208 | 209 | zc.buildout.buildout.main(args) 210 | shutil.rmtree(tmpeggs) 211 | -------------------------------------------------------------------------------- /buildout.cfg: -------------------------------------------------------------------------------- 1 | [buildout] 2 | parts = app js-build 3 | develop = . 4 | 5 | # show-picked-versions = true 6 | # update-versions-file = versions.cfg 7 | 8 | extends = release.cfg versions.cfg 9 | relative-paths = true 10 | 11 | database = dbname=kanban 12 | raven-logging = 13 | jsraven = 14 | extras = 15 | extra-configure = 16 | extra-options = 17 | 18 | [versions] 19 | zc.buildout = 20 | setuptools = 21 | 22 | [ports] 23 | app = 8000 24 | 25 | [test] 26 | recipe = zc.recipe.testrunner 27 | eggs = twotieredkanban [test] 28 | 29 | [js-build] 30 | recipe = collective.recipe.cmd 31 | on_install = true 32 | on_update = true 33 | cmds = 34 | npm install 35 | ${buildout:directory}/webpack --env.prod 36 | 37 | [zdaemon] 38 | recipe = zc.recipe.egg 39 | eggs = zdaemon 40 | 41 | [wsgirunner] 42 | recipe = zc.recipe.egg 43 | eggs = 44 | twotieredkanban ${buildout:extras} 45 | zc.zodbwsgi 46 | zc.wsgirunner 47 | Paste 48 | newt.db 49 | 50 | [py] 51 | recipe = zc.recipe.egg 52 | eggs = ${wsgirunner:eggs} 53 | ${test:eggs} 54 | interpreter = py 55 | initialization = 56 | import ZODB.config, transaction 57 | db = ZODB.config.databaseFromFile(open("${buildout:directory}/db.cfg")) 58 | conn = db.open() 59 | root = conn.root 60 | scripts = py 61 | 62 | [dbclient] 63 | recipe = zc.recipe.deployment:configuration 64 | name = ${buildout:directory}/db.cfg 65 | text = 66 | %import newt.db 67 | 68 | 69 | 70 | 71 | keep-history false 72 | 73 | 74 | dsn ${buildout:database} 75 | 76 | 77 | 78 | 79 | 80 | 81 | [paste.ini] 82 | recipe = zc.recipe.deployment:configuration 83 | static = ${buildout:directory}/static 84 | databases = 85 | configuration = 86 | %${dbclient:text} 87 | logpath = ${buildout:directory}/server.log 88 | text = 89 | [pipeline:main] 90 | pipeline = zodb reload kanban 91 | 92 | [app:kanban] 93 | use = egg:bobo 94 | bobo_resources = boboserver:static('/static', '${:static}') 95 | twotieredkanban.apibase 96 | 97 | bobo_configure = twotieredkanban.apiutil:config 98 | twotieredkanban.initializedb:config 99 | ${buildout:extra-configure} 100 | 101 | ${buildout:extra-options} 102 | 103 | auth = twotieredkanban.emailpw 104 | raven = ${buildout:jsraven} 105 | release = ${buildout:release} 106 | 107 | bobo_errors = twotieredkanban.apibase 108 | 109 | dsn = ${buildout:database} 110 | 111 | [filter:reload] 112 | use = egg:bobo#reload 113 | modules = twotieredkanban 114 | 115 | [filter:zodb] 116 | use = egg:zc.zodbwsgi 117 | ${:databases} 118 | max_connections = 4 119 | thread_transaction_manager = False 120 | initializer = twotieredkanban.initializedb:initialize 121 | 122 | [filter:lint] 123 | use = egg:Paste#lint 124 | 125 | [filter:error] 126 | use = egg:Paste#error_catcher 127 | 128 | [server:main] 129 | use = egg:twotieredkanban 130 | port = ${ports:app} 131 | dsn = ${buildout:database} 132 | 133 | [logging:main] 134 | config = 135 | 136 | level INFO 137 | 138 | path ${:logpath} 139 | 140 | ${buildout:raven-logging} 141 | 142 | 143 | [app] 144 | => wsgirunner py 145 | recipe = zc.zdaemonrecipe 146 | b = ${buildout:bin-directory} 147 | p = ${buildout:parts-directory} 148 | program = ${:b}/run-wsgi ${paste.ini:location} 149 | -------------------------------------------------------------------------------- /client/app.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import {Route, Router, IndexRoute, hashHistory} from 'react-router'; 4 | 5 | import {AdminUI, Site} from './ui/site'; 6 | import {Board} from './ui/board'; 7 | 8 | require('./styles/app.scss'); 9 | 10 | class Main extends React.Component { 11 | render() { 12 | return ( 13 |
14 | {this.props.children} 15 |
16 | ); 17 | } 18 | } 19 | 20 | const NotFound = (props) => { 21 | window.location.hash = '#/'; 22 | return
Not found
; 23 | }; 24 | 25 | ReactDOM.render( 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | , 34 | document.getElementById('app') 35 | ); 36 | -------------------------------------------------------------------------------- /client/demo/baseapi.js: -------------------------------------------------------------------------------- 1 | import indexedDB from "indexedDB"; 2 | 3 | import default_states from './default-states.json'; 4 | import sample from "Sample"; 5 | 6 | const dbname = "FeatureFlowDemo"; 7 | let open_request = null; 8 | let opened = null; 9 | 10 | const state = (board, order, props) => { 11 | const s = {board: board, order: order, explode: false, 12 | working: false, complete: false, task: false}; 13 | Object.assign(s, typeof props == 'string'? {title: props} : props); 14 | s.id = s.id || s.title; 15 | s.key = board + '/' + s.id; 16 | return s; 17 | }; 18 | 19 | export const board_states = (name) => { 20 | let order = -1; 21 | return default_states.map((props) => { 22 | order += 1; 23 | return state(name, order, props); 24 | }); 25 | }; 26 | 27 | const populate_store = (store, data) => { 28 | data.forEach((record) => { store.add(record); }); 29 | }; 30 | 31 | let open_database = () => { 32 | open_request = indexedDB.open(dbname, 2); 33 | open_request.onupgradeneeded = (ev) => { 34 | const db = ev.target.result; 35 | if (ev.oldVersion < 1) { 36 | const boards = db.createObjectStore('boards', {keyPath: 'name' }); 37 | db.createObjectStore('users', {keyPath: 'id' }); 38 | db.createObjectStore('states', {keyPath: 'key' }) 39 | .createIndex('board', 'board', {unique: false}); 40 | 41 | db.createObjectStore('tasks', {keyPath: 'id' }) 42 | .createIndex('board', 'board', {unique: false}); 43 | 44 | boards.transaction.oncomplete = () => { 45 | const trans = db.transaction(['boards', 'users', 'states', 'tasks'], 46 | 'readwrite'); 47 | populate_store(trans.objectStore('users'), sample.users); 48 | populate_store(trans.objectStore('boards'), sample.boards); 49 | sample.boards.forEach((board) => { 50 | populate_store(trans.objectStore('states'), 51 | board_states(board.name)); 52 | }); 53 | populate_store(trans.objectStore('tasks'), sample.tasks); 54 | }; 55 | } 56 | if (ev.oldVersion < 2) { 57 | db.createObjectStore('archive', {keyPath: 'id' }) 58 | .createIndex('board', 'board', {unique: false}); 59 | 60 | } 61 | }; 62 | 63 | opened = new Promise((resolve, reject) => { 64 | open_request.onerror = (ev) => { 65 | console.log("wtfopen", ev.target.errorCode); 66 | reject(ev.target.errorCode); 67 | }; 68 | open_request.onsuccess = (ev) => resolve(ev.target.result); 69 | }); 70 | }; 71 | open_database(); 72 | 73 | export class BaseAPI { 74 | 75 | constructor(model, view, cb) { 76 | this.model = model; 77 | this.view = view; 78 | this.opened = opened; 79 | opened.catch( 80 | (code) => this.handle_error( 81 | "Couldn't open feature-flow local database", 82 | code) 83 | ); 84 | this.poll(cb); 85 | } 86 | 87 | start() {} 88 | stop() {} 89 | 90 | assert_(cond, message) { 91 | if (! cond) { 92 | throw new Error('Assertion failed: ' + message); 93 | } 94 | } 95 | 96 | static test_reset(cb) { 97 | open_request.result.close(); 98 | indexedDB.deleteDatabase(dbname).onsuccess = () => { 99 | open_database(); 100 | cb(); 101 | }; 102 | } 103 | 104 | handle_error(err) { 105 | console.log(err); 106 | alert(err); 107 | } 108 | 109 | r(request, f, cb) { 110 | request.onsuccess = (ev) => { 111 | try { 112 | f(ev.target.result); 113 | } 114 | catch (err) { 115 | this.handle_error(err); 116 | if (cb) { 117 | cb(err); 118 | } 119 | } 120 | }; 121 | request.onerror = (ev) => { 122 | this.handle_error(ev); 123 | if (cb) { 124 | cb(ev); 125 | } 126 | }; 127 | } 128 | 129 | all(request, f, cb) { 130 | const results = []; 131 | request.onsuccess = (ev) => { 132 | const cursor = ev.target.result; 133 | if (cursor) { 134 | results.push(cursor.value); 135 | cursor.continue(); 136 | } 137 | else { 138 | try { 139 | f(results); 140 | } 141 | catch (err) { 142 | this.handle_error(err); 143 | if (cb) { 144 | cb(err); 145 | } 146 | } 147 | } 148 | }; 149 | request.onerror = (ev) => { 150 | this.handle_error(ev); 151 | if (cb) { 152 | cb(ev); 153 | } 154 | }; 155 | } 156 | 157 | each(request, f, cb) { 158 | request.onsuccess = (ev) => { 159 | const cursor = ev.target.result; 160 | if (cursor) { 161 | try { 162 | f(cursor.value); 163 | } 164 | catch (err) { 165 | this.handle_error(err); 166 | if (cb) { 167 | cb(err); 168 | } 169 | return; 170 | } 171 | cursor.continue(); 172 | } 173 | else { 174 | cb(); 175 | } 176 | }; 177 | request.onerror = (ev) => { 178 | this.handle_error(ev); 179 | if (cb) { 180 | cb(ev); 181 | } 182 | }; 183 | } 184 | 185 | chain(funcs, cb) { 186 | if (funcs.length == 0) { 187 | if (cb) { 188 | cb(); // done 189 | } 190 | } 191 | else { 192 | funcs[0](() => { 193 | this.chain(funcs.splice(1), cb); 194 | }); 195 | } 196 | } 197 | 198 | poll() { 199 | return this.opened.then((db) => { 200 | this.db = db; 201 | return db; 202 | }); 203 | } 204 | 205 | transaction(stores, mode, f, cb) { 206 | const trans = this.db.transaction(stores, mode); 207 | trans.onerror = (ev) => { 208 | this.handle_error(ev); 209 | if (cb) { 210 | cb(ev); 211 | } 212 | }; 213 | try { 214 | f(trans); 215 | } 216 | catch (err) { 217 | this.handle_error(err); 218 | if (cb) { 219 | cb(err); 220 | } 221 | } 222 | } 223 | 224 | update(trans, data, cb) { 225 | trans.oncomplete = () => { 226 | if (data) { 227 | if (data.user) { 228 | this.user = data.user; 229 | } 230 | this.model.update(data); 231 | } 232 | else { 233 | this.model.NotFound = true; 234 | } 235 | this.view.setState({model: this.model}); 236 | if (cb) { 237 | cb(this, data); 238 | } 239 | }; 240 | } 241 | 242 | boards(trans, f) { 243 | this.all(trans.objectStore('boards').openCursor(), f); 244 | } 245 | 246 | users(trans, f) { 247 | this.all(trans.objectStore('users').openCursor(), (users) => { 248 | const user = users.filter((u) => u.current)[0]; 249 | f(users, user); 250 | }); 251 | } 252 | 253 | switch_user(uid, cb) { 254 | this.transaction('users', 'readwrite', (trans) => { 255 | const users = trans.objectStore('users'); 256 | this.r(users.get(this.user.id), (user) => { 257 | user.current = false; 258 | this.r(users.put(user), () => { 259 | this.r(users.get(uid), (user) => { 260 | user.current = true; 261 | this.r(users.put(user), () => { 262 | this.update(trans, {user: user}, cb); 263 | }); 264 | }); 265 | }); 266 | }); 267 | }); 268 | } 269 | 270 | update_profile(data, cb) { 271 | if (data.id !== this.user.id) { 272 | this.handle_error("update_profile: Invalid user id"); 273 | } 274 | else { 275 | const user = 276 | Object.assign( 277 | Object.assign({}, this.user), 278 | {name: data.name, nick: data.nick, email: data.email}); 279 | this.transaction('users', 'readwrite', (trans) => { 280 | this.r(trans.objectStore('users').put(user), () => { 281 | this.users(trans, (users, user) => { 282 | this.update(trans, {user: user, site: {users: users}}, cb); 283 | }); 284 | }); 285 | }); 286 | } 287 | } 288 | 289 | add_board(name, cb) { 290 | this.transaction('boards', 'readwrite', (trans) => { 291 | const store = trans.objectStore('boards'); 292 | 293 | this.all(store.openCursor(name), (boards) => { 294 | if (boards.length > 0) { 295 | this.handle_error("There is already a board named " + name); 296 | } 297 | else { 298 | this.r(store.add({name: name, title: '', description: ''}), () => { 299 | this.all(store.openCursor(), (boards) => { 300 | this.update(trans, {site: {boards: boards}}, cb); 301 | }); 302 | }); 303 | } 304 | }); 305 | }); 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /client/demo/default-states.json: -------------------------------------------------------------------------------- 1 | ../../default-states.json -------------------------------------------------------------------------------- /client/demo/indexeddb.js: -------------------------------------------------------------------------------- 1 | // Indirect indexedDB to allow replacement with fake in tests 2 | export default window.indexedDB; 3 | 4 | -------------------------------------------------------------------------------- /client/demo/intro.html: -------------------------------------------------------------------------------- 1 |
2 |

Welcome to the Valuenator Demo

3 | 4 |

In this demo version of Valuenator, you can work with tasks just as you 5 | would in the on-line version, except that:

6 |
    7 |
  • All data is stored locally and persistently in your browser. 8 | (If you reload the demo, the data will still be there.)
  • 9 |
  • Therefore, you can't share tasks with other people.
  • 10 |
  • Searching in the demo version uses substring search, as opposed 11 | to the on-line version which uses full-text (language aware) 12 | search.
  • 13 |
14 |
15 |

Concepts

16 |

The goal of feature flow is to provide value as quickly as 17 | possible. Examples of value include enabling a customer to do something 18 | new, or more easily, increasing efficiency, or learning something 19 | about a customer or market

20 |
21 |
Boards
22 |

A board displays the features a team is 23 | working on. You can navigate to a board by clicking on it's 24 | name in the board menu, which you can view by clicking on 25 | the menu icon in the upper-left corner of the window. (You 26 | can get back here by clicking on the home icon in the 27 | menu.)

28 |

If you're an administrator, you can add a 29 | board from this menu.

30 |
31 |
Features
32 |

Features are the focus of team work.

33 |

At a high level, a board shows features, in 34 | the backlog, or in one of the feature states

35 |
36 |
Tasks
37 |
Features are implemented by one or more tasks.
38 |
39 |
40 |
41 |

Using

42 |

Drag feature and task cards between states.

43 |

Click on plus buttons to add features or tasks.

44 |

Click on pencil buttons to edit features.

45 |

Click on tasks to edit them.

46 |
47 |
48 | -------------------------------------------------------------------------------- /client/demo/intro.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import intro_html from './intro.html'; 4 | 5 | const inner_html = {__html: intro_html}; 6 | 7 | export default (props) => ( 8 |
9 |
10 | ); 11 | -------------------------------------------------------------------------------- /client/demo/siteapi.js: -------------------------------------------------------------------------------- 1 | import uuid from 'uuid/v1'; 2 | 3 | import {Site} from '../model/site'; 4 | 5 | import {BaseAPI} from './baseapi'; 6 | 7 | export class SiteAPI extends BaseAPI { 8 | 9 | constructor(view, cb) { 10 | super(new Site(), view, cb); 11 | } 12 | 13 | poll(cb) { 14 | super.poll().then((db) => { 15 | this.transaction(['boards', 'users'], 'readonly', (trans) => { 16 | this.boards(trans, (boards) => { 17 | this.users(trans, (users, user) => { 18 | this.update( 19 | trans, 20 | {site: {boards: boards, users: users}, user: user}, 21 | cb); 22 | }); 23 | }); 24 | }); 25 | }); 26 | } 27 | 28 | add_user(email, name, admin, cb) { 29 | this.transaction('users', 'readwrite', (trans) => { 30 | const users_store = trans.objectStore('users'); 31 | this.r(users_store.add( 32 | {id: uuid(), email: email, name: name, admin: admin, nick:''}), () => { 33 | this.all(users_store.openCursor(), (users) => { 34 | this.update(trans, {site: {users: users}}, cb); 35 | }, cb); 36 | }, cb); 37 | }, cb); 38 | } 39 | 40 | change_user_type(uid, admin, cb) { 41 | this.transaction('users', 'readwrite', (trans) => { 42 | const users_store = trans.objectStore('users'); 43 | this.r(users_store.get(uid), (user) => { 44 | user.admin = admin; 45 | this.r(users_store.put(user), () => { 46 | this.all(users_store.openCursor(), (users) => { 47 | this.update(trans, {site: {users: users}}, cb); 48 | }, cb); 49 | }, cb); 50 | }, cb); 51 | }, cb); 52 | } 53 | 54 | get_requests(f) { 55 | setTimeout(() => f([]), 10); 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /client/demo/ui.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {IconMenu, MenuItem} from 'react-toolbox/lib/menu'; 3 | 4 | import {UserAvatar, UserSelect, UserProfile} from '../ui/who'; 5 | import {Dialog, DialogBase, show_dialog} from '../ui/dialog'; 6 | 7 | class UserSwitch extends DialogBase { 8 | 9 | render() { 10 | const {model, api} = this.props; 11 | return ( 12 | api.switch_user(this.state.user)} 15 | type="small" 16 | > 17 | 19 | 20 | ); 21 | } 22 | } 23 | 24 | export class Avatar extends React.Component { 25 | render () { 26 | const {model, api} = this.props; 27 | const user = model.user; 28 | return ( 29 | } 30 | position='topRight' menuRipple> 31 | 33 | api.update_profile(data)} /> 35 | 37 | 38 | 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /client/emailpw/ui.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {IconMenu, MenuItem} from 'react-toolbox/lib/menu'; 3 | 4 | import {show_dialog} from '../ui/dialog'; 5 | import {UserAvatar, UserProfile} from "../ui/who"; 6 | 7 | const logout = () => window.location.href = "/auth/logout"; 8 | 9 | export class Avatar extends React.Component { 10 | render(props) { 11 | const {model, api} = this.props; 12 | const user = model.user; 13 | 14 | const profile = () => { 15 | this.refs.profile.show( 16 | { id: user.id, name: user.name, email: user.email, nick: user.nick } 17 | ); 18 | }; 19 | 20 | return ( 21 | } 22 | position='topRight' menuRipple> 23 | 25 | api.put('/auth/user', data)} /> 27 | 28 | 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/kb.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Two-Tiered Kanban 5 | 6 | 8 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /client/model/apibase.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import Raven from 'raven-js'; 3 | 4 | import version from '../version.js'; 5 | 6 | const CancelToken = axios.CancelToken; 7 | 8 | export class APIBase { 9 | 10 | constructor(model, view, base) { 11 | this.model = model; 12 | this.view = view; 13 | this.base = base; 14 | this.generation = 0; 15 | this.config = { 16 | transformRequest: axios.defaults.transformRequest.concat( 17 | [(data) => this.transform_request(data)]), 18 | transformResponse: [(data) => this.transform_response(data)], 19 | responseType: 'json', 20 | headers: {'x-generation': this.generation} 21 | }; 22 | 23 | this.calls = -1; // -1 to account for long poll 24 | } 25 | 26 | start() { 27 | if (! this.active) { 28 | this.poll_route = 'poll'; 29 | this.active = true; 30 | this.poll(); 31 | } 32 | } 33 | 34 | stop() { 35 | this.active = false; 36 | if (this.cancel) { 37 | this.cancel(); 38 | this.cancel = undefined; 39 | console.log('cancel'); 40 | } 41 | } 42 | 43 | raven(err) {} 44 | 45 | transform_request(data) { 46 | this.calls += 1; 47 | if (this.calls == 1) { 48 | this.view.setState({calls: this.calls}); 49 | } 50 | return data; 51 | } 52 | 53 | transform_response(data) { 54 | this.calls -= 1; 55 | if (this.calls == 0) { 56 | this.view.setState({calls: this.calls}); 57 | } 58 | 59 | if (data && data.updates) { 60 | const updates = data.updates; 61 | if (updates.generation > this.generation) { 62 | this.model.update(updates); 63 | if (updates.user && this.ravonic) { 64 | Raven.setUserContext({email: updates.user.email, 65 | id: updates.user.id}); 66 | } 67 | if (updates.raven) { 68 | updates.raven.options.release = version; 69 | Raven 70 | .config(updates.raven.url, updates.raven.options) 71 | .install(); 72 | this.ravonic = true; 73 | this.raven = (err) => Raven.captureException(err); 74 | if (this.model.user) { 75 | Raven.setUserContext({email: this.model.user.email, 76 | id: this.model.user.id}); 77 | } 78 | } 79 | this.config.headers['x-generation'] = updates.generation; 80 | if (updates.zoid) { 81 | this.config.headers['x-generation-zoid'] = updates.zoid; 82 | } 83 | this.generation = updates.generation; 84 | this.view.setState({model: this.model}); 85 | } 86 | } 87 | return data; 88 | } 89 | 90 | handle_error(err) { 91 | if (err.request || err.response) { 92 | console.log(err); 93 | if (err.message != "Network Error") { 94 | this.raven(err); 95 | } 96 | } 97 | this.transform_response({}); 98 | throw err; 99 | } 100 | 101 | get(url, data) { 102 | return axios.get(url[0] == '/' ? url : this.base + url, this.config) 103 | .catch((e) => this.handle_error(e)); 104 | } 105 | 106 | poll() { 107 | if (this.active) { 108 | console.log(this.poll_route); 109 | let config = this.config; 110 | if (this.poll_route == 'longpoll') { 111 | config = Object.assign({ 112 | cancelToken: new CancelToken((c) => { 113 | this.cancel = c; 114 | }) 115 | }, config); 116 | } 117 | axios.get(this.base + this.poll_route, config) 118 | .catch((e) => { 119 | if (axios.isCancel(e)) { 120 | this.transform_response({}); 121 | console.log('Request canceled', e.message); 122 | } 123 | else { 124 | this.handle_error(e); 125 | } 126 | }) 127 | .then(() => { 128 | this.poll_route = 'longpoll'; 129 | this.poll(); 130 | }) 131 | .catch((err) => { 132 | console.log(this.poll_route, "failed", err); 133 | setTimeout(() => this.poll(), 9999); 134 | if (err.response && err.response.status === 404) { 135 | this.model.NotFound = true; 136 | this.view.setState({model: this.model}); 137 | } 138 | }); 139 | } 140 | } 141 | 142 | delete(url) { 143 | return axios.delete(url[0] == '/' ? url : this.base + url, this.config) 144 | .catch((e) => this.handle_error(e)); 145 | } 146 | 147 | post(url, data) { 148 | return axios.post(url[0] == '/' ? url : this.base + url, data, this.config) 149 | .catch((e) => this.handle_error(e)); 150 | } 151 | 152 | put(url, data) { 153 | return axios.put(url[0] == '/' ? url : this.base + url, data, this.config) 154 | .catch((e) => this.handle_error(e)); 155 | } 156 | 157 | add_board(name) { 158 | this.post('boards', {name: name, title: '', description: ''}); 159 | } 160 | }; 161 | -------------------------------------------------------------------------------- /client/model/board.js: -------------------------------------------------------------------------------- 1 | class TaskContainer { 2 | 3 | constructor() { 4 | this.total_size = 0; 5 | this.total_completed = 0; 6 | this.count = 0; 7 | this.subtasks_by_state = {}; // state_id -> [task] 8 | this.rev = 0; 9 | } 10 | 11 | subtasks(state) { 12 | if (! this.subtasks_by_state[state]) { 13 | this.subtasks_by_state[state] = []; 14 | } 15 | return this.subtasks_by_state[state]; 16 | } 17 | 18 | add_subtask(task) { 19 | this.subtasks(task.state.id).push(task); 20 | this.subtasks().push(task); 21 | this.update_stats(); 22 | this.rev += 1; 23 | } 24 | 25 | ar_remove(list, element) { 26 | return list.splice(list.indexOf(element), 1); 27 | } 28 | 29 | remove_subtask(task) { 30 | this.ar_remove(this.subtasks(task.state.id), task); 31 | this.ar_remove(this.subtasks(), task); 32 | this.update_stats(); 33 | this.rev += 1; 34 | } 35 | 36 | update_stats() { 37 | this.total_completed = 0; 38 | this.total_size = 0; 39 | this.count = 0; 40 | this.subtasks().forEach((task) => { 41 | this.count += 1; 42 | this.total_size += task.size; 43 | if (task.history[task.history.length - 1].complete) { 44 | this.total_completed += task.size; 45 | } 46 | }); 47 | } 48 | 49 | cmp_order(a, b) { 50 | return a.order < b.order ? -1 : (a.order > b.order ? 1 : 0); 51 | } 52 | 53 | sort(state) { 54 | this.subtasks(state).sort(this.cmp_order); 55 | this.subtasks().sort(this.cmp_order); 56 | } 57 | } 58 | 59 | class Task extends TaskContainer { 60 | 61 | constructor(id, props) { 62 | super(); 63 | this.id = id; 64 | this.title = props.title; 65 | this.description = props.description; 66 | this.state = props.state; 67 | this.order = props.order; 68 | this.blocked = props.blocked; 69 | this.assigned = props.assigned; 70 | this.size = props.size || 1; 71 | this.complete = props.complete; 72 | this.parent = props.parent; 73 | this.history = props.history; 74 | } 75 | 76 | update(task) { 77 | this.title = task.title; 78 | this.description = task.description; 79 | this.blocked = task.blocked; 80 | this.created = task.created; 81 | this.assigned = task.assigned; 82 | this.user = task.user; 83 | this.size = task.size; 84 | this.complete = task.complete; 85 | this.parent = task.parent; 86 | this.state = task.state; 87 | this.order = task.order; 88 | this.history = task.history; 89 | 90 | // Give React a little help: 91 | this.rev += 1; 92 | if (this.parent) { 93 | this.parent.rev += 1; 94 | } 95 | } 96 | } 97 | 98 | export class Board extends TaskContainer { 99 | 100 | constructor(name) { 101 | super(); 102 | this.name = name; 103 | this.title = ''; 104 | this.description = ''; 105 | this.boards = []; 106 | this.users = []; 107 | this.users_by_id = {}; 108 | this.user = {email: ''}; 109 | 110 | this.tasks = {}; // {id -> task} for all tasks 111 | this.all_tasks = []; 112 | 113 | this.states = []; 114 | this.project_states = []; 115 | this.task_states = []; 116 | this.states_by_id = {}; // {id -> state 117 | this.archive_count = 0; 118 | 119 | this.search = {}; // Search results 120 | } 121 | 122 | add_task(task) { 123 | const old = this.tasks[task.id]; 124 | let add = true; 125 | let sort = true; 126 | 127 | task.state = this.states_by_id[task.state]; 128 | if (! task.state) { 129 | console.log(`Invalid state id ${task.state} for ${task.id}`); 130 | task.state = this.states_by_id[ 131 | task.parent ? 132 | this.default_task_state_id : 133 | this.default_project_state_id]; 134 | } 135 | if (task.assigned) { 136 | task.user = this.users_by_id[task.assigned]; 137 | } 138 | 139 | if (old) { 140 | if (task.parent != old.parent || task.state.id != old.state.id) { 141 | (old.parent ? old.parent : this).remove_subtask(old); 142 | } 143 | else { 144 | add = false; 145 | sort = task.order != old.order; 146 | } 147 | old.update(task); 148 | task = old; 149 | } 150 | else { 151 | this.tasks[task.id] = task; 152 | this.all_tasks.push(task); 153 | } 154 | 155 | const parent = task.parent ? task.parent : this; 156 | if (add) { 157 | parent.add_subtask(task); 158 | } 159 | else { 160 | parent.update_stats(); 161 | } 162 | 163 | if (sort) { 164 | parent.sort(task.state.id); 165 | } 166 | 167 | parent.rev += 1; 168 | } 169 | 170 | update(updates) { 171 | if (updates.board) { 172 | if (updates.board.name != undefined) { 173 | this.name = updates.board.name; 174 | } 175 | if (updates.board.title != undefined) { 176 | this.title = updates.board.title; 177 | } 178 | if (updates.board.description != undefined) { 179 | this.description = updates.board.description; 180 | } 181 | if (updates.board.archive_count != undefined) { 182 | this.archive_count = updates.board.archive_count; 183 | delete this.search.archive; // Clear search results 184 | } 185 | } 186 | 187 | if (updates.site) { 188 | Object.assign(this, updates.site); 189 | this.users_by_id = {}; 190 | this.users.forEach((u) => { 191 | this.users_by_id[u.id] = u; 192 | }); 193 | this.users.sort( 194 | (u1, u2) => u1.name < u2.name ? -1 : (u1.name > u2.name ? 1 : 0)); 195 | } 196 | 197 | if (updates.user) { 198 | this.user = updates.user; 199 | } 200 | 201 | if (updates.states) { 202 | updates.states.adds.forEach((state) => { 203 | if (this.states_by_id[state.id]) { 204 | Object.assign(this.states_by_id[state.id], state); 205 | } 206 | else { 207 | state.substates = this.task_states; 208 | this.states.push(state); 209 | this.states_by_id[state.id] = state; 210 | state.projects = this.subtasks(state.id); 211 | } 212 | }); 213 | this.states.sort(this.cmp_order); 214 | this.project_states = this.states.filter((state) => ! state.task); 215 | this.task_states.splice( 216 | 0, this.task_states.length, 217 | ... this.states.filter((state) => state.task) 218 | ); 219 | this.default_project_state_id = this.project_states[0].id; 220 | this.default_task_state_id = this.task_states[0].id; 221 | } 222 | 223 | if (updates.tasks) { 224 | if (updates.tasks.contents) { 225 | this.subtasks_by_state = {}; 226 | this.tasks = {}; 227 | this.all_tasks = []; 228 | updates.tasks.adds = updates.tasks.contents; 229 | } 230 | 231 | if (updates.tasks.adds) { 232 | updates.tasks.adds.forEach((task) => { 233 | if (! task.parent) { 234 | this.add_task(new Task( 235 | task.id, 236 | { 237 | title: task.title, 238 | description: task.description, 239 | state: task.state ? task.state : this.default_project_state_id, 240 | order: task.order, 241 | history: task.history 242 | } 243 | )); 244 | } 245 | }); 246 | 247 | // tasks 248 | // Note that we deal with tasks in a second pass so we know 249 | // projects are in place. 250 | updates.tasks.adds.forEach((task) => { 251 | if (task.parent) { 252 | this.add_task( 253 | new Task( 254 | task.id, 255 | { 256 | title: task.title, 257 | description: task.description, 258 | state: task.state ? task.state : this.default_task_state_id, 259 | order: task.order, 260 | blocked: task.blocked, 261 | assigned: task.assigned, 262 | size: task.size, 263 | history: task.history, 264 | parent: this.tasks[task.parent] 265 | } 266 | )); 267 | } 268 | }); 269 | 270 | this.all_tasks.sort(this.cmp_order); 271 | } 272 | 273 | if (updates.tasks.removals) { 274 | updates.tasks.removals.forEach((task_id) => { 275 | const task = this.tasks[task_id]; 276 | (task.parent || this).remove_subtask(task); 277 | delete this.tasks[task_id]; 278 | this.ar_remove(this.all_tasks, task); 279 | }); 280 | } 281 | } 282 | 283 | if (updates.search) { 284 | // Search results flow much like data changes 285 | Object.assign(this.search, updates.search); 286 | } 287 | } 288 | 289 | order(before_id, front) { 290 | const r = Math.random(); 291 | if (before_id != undefined) { 292 | const before = this.tasks[before_id]; 293 | const i = this.all_tasks.indexOf(before); 294 | if (i == 0) { 295 | return before.order - .5 - r; 296 | } 297 | else { 298 | const d = before.order - this.all_tasks[i - 1].order; 299 | return before.order - (r * .5 + .25) * d; 300 | } 301 | } 302 | else { 303 | if (this.all_tasks.length > 0) { 304 | if (front) { 305 | return this.all_tasks[0].order - .5 - r; 306 | } 307 | else { 308 | return this.all_tasks.slice(-1)[0].order + .5 + r; 309 | } 310 | } 311 | else { 312 | return 0; 313 | } 314 | } 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /client/model/boardapi.js: -------------------------------------------------------------------------------- 1 | import {APIBase} from './apibase'; 2 | import {Board} from './board'; 3 | 4 | export class BoardAPI extends APIBase { 5 | 6 | constructor(view, name) { 7 | super(new Board(name), view, '/board/' + name + '/'); 8 | } 9 | 10 | rename(name) { 11 | this.put('', {name: name}); 12 | } 13 | 14 | add_project(props) { 15 | this.post('projects', 16 | {title: props.title, description: props.description, 17 | order: this.model.order(undefined, true)}); 18 | } 19 | 20 | add_task(props) { 21 | this.post('project/' + props.project_id, { 22 | title: props.title, 23 | description: props.description, 24 | size: props.size, 25 | blocked: props.blocked, 26 | assigned: props.assigned, 27 | order: this.model.order(undefined, true) 28 | }); 29 | } 30 | 31 | update_task(id, props) { 32 | this.put('tasks/' + id, { 33 | title: props.title, 34 | description: props.description, 35 | size: props.size, 36 | blocked: props.blocked, 37 | assigned: props.assigned 38 | }); 39 | } 40 | 41 | remove(id) { 42 | this.delete('tasks/' + id); 43 | } 44 | 45 | move(task_id, parent_id, state_id, before_id, front) { 46 | const order = this.model.order(before_id, front); 47 | this.put(`move/${task_id}`, 48 | {state_id: state_id, parent_id: parent_id, order: order}); 49 | } 50 | 51 | archive(feature_id) { 52 | this.post('archive/' + feature_id, {}); 53 | } 54 | 55 | restore(feature_id) { 56 | this.delete('archive/' + feature_id); 57 | } 58 | 59 | get_archived(search, start, size, f) { 60 | search = search ? '&text=' + encodeURIComponent(search) : ''; 61 | this.get('archive?start=' + start + '&size=' + size + search).then((r) => { 62 | r.data.start = start; 63 | this.model.update({search: {archive: r.data}}); 64 | this.view.setState({model: this.model}); 65 | }); 66 | } 67 | 68 | export_url(f) { 69 | f(this.base + 'export'); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /client/model/hastext.js: -------------------------------------------------------------------------------- 1 | // XXX this will probably break at some point when the 2 | // rich-text-editor changes. 3 | export const has_text = (text) => text && text != "


"; 4 | -------------------------------------------------------------------------------- /client/model/site.js: -------------------------------------------------------------------------------- 1 | export class Site { 2 | 3 | constructor() { 4 | this.boards = []; 5 | this.users = []; 6 | this.user = {email: ''}; 7 | } 8 | 9 | update(data) { 10 | if (data.site) { 11 | Object.assign(this, data.site); 12 | } 13 | if (data.user) { 14 | this.user = data.user; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/model/siteapi.js: -------------------------------------------------------------------------------- 1 | import {APIBase} from './apibase'; 2 | import {Site} from './site'; 3 | 4 | export class SiteAPI extends APIBase { 5 | constructor(view) { 6 | super(new Site(), view, '/site/'); 7 | } 8 | 9 | add_user(email, name, admin) { 10 | this.post('/auth/invites', {email: email, name: name || '', admin: admin}); 11 | } 12 | 13 | change_user_type(id, admin) { 14 | this.put('/auth/users/' + id + '/type', {admin: admin}); 15 | } 16 | 17 | get_requests(f) { 18 | this.get('/auth/requests').then((resp) => { 19 | f(resp.data.requests); 20 | }); 21 | } 22 | 23 | approve(email, f) { 24 | this.put('/auth/requests/'+email).then(f); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /client/styles/app.scss: -------------------------------------------------------------------------------- 1 | 2 | .kb-board { 3 | .kb-table { 4 | display: flex; 5 | width: 100%; 6 | border: 1px solid black; 7 | align-items: stretch; 8 | > div { 9 | flex-grow: 1; 10 | display: flex; 11 | } 12 | } 13 | } 14 | 15 | .kb-project { 16 | width: auto; 17 | margin: 5px; 18 | background-color: #f9f9f9; 19 | h1 { 20 | font-size: 120%; 21 | text-align: center; 22 | } 23 | } 24 | 25 | .kb-task { 26 | width: auto; 27 | margin: 3px; 28 | background-color: #fff999; 29 | h1 { 30 | font-size: 100%; 31 | } 32 | .kb-assigned-avatar { 33 | margin-left: .5em; 34 | height: 20px; 35 | width: 20px; 36 | } 37 | .kb-revealable { 38 | padding: .5em; 39 | padding-right: 0; 40 | } 41 | } 42 | 43 | .kb-divider { 44 | height: 1ex; 45 | transition: height .3s; 46 | } 47 | 48 | .kb-divider.dragover { 49 | height: 4ex; 50 | background-color: #ccff99; 51 | } 52 | 53 | .kb-tail { 54 | flex-grow: 1; 55 | height: 2ex; 56 | } 57 | 58 | .kb-column { 59 | flex-grow: 1; 60 | display: flex; 61 | flex-direction: column; 62 | border-right: 1px solid grey; 63 | /* min-height: 14ex; */ 64 | 65 | > h1 { 66 | margin: 0; 67 | font-size: 120%; 68 | border-bottom: 2px solid black; 69 | text-align: center; 70 | padding: 3px; 71 | background-color: #eee; 72 | white-space: nowrap; 73 | } 74 | 75 | .kb-task.blocked { 76 | background-color: #fdd; 77 | } 78 | 79 | .kb-unordered-column { 80 | flex-grow: 1; 81 | padding-top: 1ex; /* to match top divider in ordered columns*/ 82 | } 83 | 84 | .kb-unordered-column.dragover { 85 | background-color: #ccff99; 86 | } 87 | } 88 | 89 | .kb-table.kb-empty > .kb-column { 90 | height: 14ex; 91 | } 92 | 93 | .kb-column .working { 94 | .kb-task { 95 | background-color: #dfd; 96 | } 97 | } 98 | 99 | .kb-scrollable { 100 | overflow-y: scroll; 101 | } 102 | 103 | .kb-user { 104 | display: flex; 105 | flexDirection: row; 106 | img { 107 | margin-right: 1em; 108 | } 109 | } 110 | 111 | .kb-field-row { 112 | display: flex; 113 | align-items: flex-end; 114 | flex-wrap: wrap; 115 | .kb-flex-grow { 116 | flex-grow: 1; 117 | } 118 | > * { 119 | margin-right: 2em; 120 | } 121 | } 122 | 123 | .kb-assigned { 124 | min-width: 20em; 125 | } 126 | 127 | .kb-task-size { 128 | width: 5em; 129 | } 130 | 131 | 132 | .kb-w-right-thing { 133 | display: flex; 134 | align-items: baseline; 135 | flex-wrap: wrap; 136 | justify-content: space-between; 137 | } 138 | 139 | .kb-w-right-thing.kb-w-right-thing-center { 140 | align-items: center; 141 | } 142 | 143 | .kb-w-right-thing.kb-revealable { 144 | align-items: flex-start; 145 | flex-wrap: nowrap; 146 | } 147 | 148 | .kb-backlog { 149 | margin: 0 1em 0 1em; 150 | .kb-column { 151 | border: none; 152 | > h1 { 153 | background-color: white; 154 | border: none; 155 | } 156 | > h1::after { 157 | content: ":" 158 | } 159 | } 160 | } 161 | 162 | .kb-the-bag { 163 | background-color: #FFDF00; 164 | padding: 1em; 165 | margin-top: 1em; 166 | margin-left: 3em; 167 | margin-right: 1em; 168 | border-radius: 1em; 169 | border-style: solid; 170 | 171 | .kb-archived-feature { 172 | padding-left: 1em; 173 | .kb-archived-task { 174 | padding-left: 1em; 175 | } 176 | } 177 | 178 | h1 { 179 | font-size: 120%; 180 | text-align: center; 181 | } 182 | 183 | h2 { 184 | font-size: 100%; 185 | text-decoration: underline; 186 | } 187 | 188 | h3 { 189 | text-decoration: none; 190 | } 191 | } 192 | .kb-the-bag.dragover { 193 | border: 5px solid #ccff99; 194 | } 195 | 196 | .kb-batch { 197 | text-align: center; 198 | .kb-batch-position { 199 | font-weight: bold; 200 | vertical-align: -20%; 201 | } 202 | } 203 | 204 | body { 205 | font-family: Roboto; 206 | margin: 0; 207 | } 208 | 209 | .kb-button-row { 210 | display: flex; 211 | justify-content: center; 212 | } 213 | 214 | .kb-version { 215 | width: 100%; 216 | text-align: center; 217 | } 218 | 219 | .kb-frame-nav { 220 | button { 221 | color: #bbb !important; 222 | } 223 | } 224 | 225 | .kb-input-tip { 226 | font-size: 80%; 227 | vertical-align: 200%; 228 | } 229 | 230 | .kb-intro { 231 | margin: 1em; 232 | } 233 | 234 | p.kb-warning { 235 | font-weight: bold; 236 | } 237 | 238 | .kb-users, .kb-requests { 239 | th { 240 | text-align: left; 241 | padding-left: 2em; 242 | border-bottom: 1px solid black; 243 | } 244 | td { 245 | padding-left: 2em; 246 | } 247 | table { 248 | border-bottom: 1px solid black; 249 | border-collapse: collapse 250 | } 251 | } 252 | 253 | .kb-small-avatar { 254 | height: 20px; 255 | width: 20px; 256 | } 257 | 258 | .kb-progress { 259 | position: fixed; 260 | top: 0; 261 | left: 0; 262 | z-index: 1; 263 | } 264 | 265 | .kb-task-board { 266 | position: relative; 267 | 268 | .kb-table { 269 | padding-right: 20%; 270 | } 271 | 272 | .kb-column.complete { 273 | position: absolute; 274 | top: 0px; 275 | right: 0px; 276 | height: 100%; 277 | width: 20%; 278 | border-top: 1px solid grey; 279 | border-right: 1px solid grey; 280 | border-bottom: 1px solid black; 281 | .kb-unordered-column { 282 | overflow-y: scroll; 283 | } 284 | .kb-task { 285 | background-color: #ddf; 286 | } 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /client/tests/sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "users": [ 3 | {"id": "jaci", "nick": "jaci", "email": "jaci@example.com", 4 | "name": "Jaci Admi", "admin": true}, 5 | {"id": "ryou", "nick": "ryou", "email": "ryou@example.com", 6 | "name": "Ryou Bosso", "admin": true, "current": true}, 7 | {"id": "kiran", "nick": "kiran", "email": "kiran@example.com", 8 | "name": "Kiran Persons"}, 9 | {"id": "gal", "nick": "gal", "email": "gal@example.com", 10 | "name": "Gal Humana"}, 11 | {"id": "alex", "nick": "alex", "email": "alex@example.com", 12 | "name": "Alex Peeple"}, 13 | {"id": "cas", "nick": "cas", "email": "cas@example.com", 14 | "name": "Cas Emplo"} 15 | ], 16 | "boards": [ 17 | { 18 | "name": "sample", 19 | "title": "Sample board", 20 | "description": 21 | "This sample board provides an example board with sample projects and tasks" 22 | } 23 | ], 24 | "tasks": [ 25 | { 26 | "board": "sample", 27 | "description": "", 28 | "history": [{"start": "2017-06-04T22:29:54.031635", "state": "Backlog"}], 29 | "id": "proto", 30 | "order": 1, 31 | "title": "Prototype board", 32 | "state": "Ready" 33 | }, 34 | { 35 | "board": "sample", 36 | "description": "", 37 | "history": [{"start": "2017-06-04T22:29:54.032246", "state": "ready"}], 38 | "id": "states", 39 | "order": 2, 40 | "parent": "proto", 41 | "size": 2, 42 | "title": "State model", 43 | "state": "ready" 44 | }, 45 | { 46 | "board": "sample", 47 | "description": "", 48 | "history": [{"start": "2017-06-04T22:29:54.032266", "state": "ready"}], 49 | "id": "api", 50 | "order": 3, 51 | "parent": "proto", 52 | "size": 3, 53 | "title": "Define API with memory implementation", 54 | "state": "ready" 55 | }, 56 | { 57 | "board": "sample", 58 | "description": "", 59 | "history": [{"start": "2017-06-04T22:29:54.032278", "state": "ready"}], 60 | "id": "routes", 61 | "order": 4, 62 | "parent": "proto", 63 | "size": 1, 64 | "title": "UI routes/screens", 65 | "state": "ready" 66 | }, 67 | { 68 | "board": "sample", 69 | "description": "", 70 | "history": [{"start": "2017-06-04T22:29:54.032300", "state": "ready"}], 71 | "id": "add_board", 72 | "order": 5, 73 | "parent": "proto", 74 | "size": 1, 75 | "title": "Add board in site UI", 76 | "state": "ready" 77 | }, 78 | { 79 | "board": "sample", 80 | "description": "", 81 | "history": [{"start": "2017-06-04T22:29:54.032313", "state": "ready"}], 82 | "id": "layout", 83 | "order": 6, 84 | "parent": "proto", 85 | "size": 1, 86 | "title": "Board layout", 87 | "state": "ready" 88 | }, 89 | { 90 | "board": "sample", 91 | "description": "", 92 | "history": [{"start": "2017-06-04T22:29:54.032326", "state": "ready"}], 93 | "id": "projects", 94 | "order": 7, 95 | "parent": "proto", 96 | "size": 1, 97 | "title": "Add/edit projects", 98 | "state": "ready" 99 | }, 100 | { 101 | "board": "sample", 102 | "description": "", 103 | "history": [{"start": "2017-06-04T22:29:54.032338", "state": "ready"}], 104 | "id": "rasks", 105 | "order": 8, 106 | "parent": "proto", 107 | "size": 1, 108 | "title": "Add/edit tasks", 109 | "state": "ready" 110 | }, 111 | { 112 | "board": "sample", 113 | "description": "", 114 | "history": [{"start": "2017-06-04T22:29:54.032350", "state": "ready"}], 115 | "id": "expand", 116 | "order": 9, 117 | "parent": "proto", 118 | "size": 1, 119 | "title": "Expand/hide projects", 120 | "state": "ready" 121 | }, 122 | { 123 | "board": "sample", 124 | "description": "", 125 | "history": [{"start": "2017-06-04T22:29:54.032361", "state": "ready"}], 126 | "id": "project_dnd", 127 | "order": 10, 128 | "parent": "proto", 129 | "size": 1, 130 | "title": "Implement project drag and drop", 131 | "state": "ready" 132 | }, 133 | { 134 | "board": "sample", 135 | "description": "", 136 | "history": [{"start": "2017-06-04T22:29:54.032372", "state": "ready"}], 137 | "id": "dev_mode", 138 | "order": 11, 139 | "parent": "proto", 140 | "size": 1, 141 | "title": "Dev-mode ptojects and task boards", 142 | "state": "ready" 143 | }, 144 | { 145 | "board": "sample", 146 | "description": "", 147 | "history": [{"start": "2017-06-04T22:29:54.032381", "state": "ready"}], 148 | "id": "task_dnd", 149 | "order": 12, 150 | "parent": "proto", 151 | "size": 1, 152 | "title": "Task drag and drop", 153 | "state": "ready" 154 | }, 155 | { 156 | "board": "sample", 157 | "description": "", 158 | "history": [{"start": "2017-06-04T22:29:54.032391", "state": "ready"}], 159 | "id": "completion", 160 | "order": 13, 161 | "parent": "proto", 162 | "size": 1, 163 | "title": "Project completion metrics", 164 | "state": "ready" 165 | }, 166 | { 167 | "board": "sample", 168 | "description": "", 169 | "history": [{"start": "2017-06-04T22:29:54.032407", "state": "Backlog"}], 170 | "id": "persistence", 171 | "order": 14, 172 | "title": "Persistence", 173 | "state": "Ready" 174 | }, 175 | { 176 | "board": "sample", 177 | "description": "", 178 | "history": [{"start": "2017-06-04T22:29:54.032417", "state": "ready"}], 179 | "id": "idb", 180 | "order": 15, 181 | "parent": "persistence", 182 | "size": 2, 183 | "title": "IndexedDB/Demo API Boards", 184 | "state": "ready" 185 | }, 186 | { 187 | "board": "sample", 188 | "description": "", 189 | "history": [{"start": "2017-06-04T22:29:54.032427", "state": "ready"}], 190 | "id": "idbp", 191 | "order": 16, 192 | "parent": "persistence", 193 | "size": 2, 194 | "title": "IndexedDB/Demo API Projects and tasks", 195 | "state": "ready" 196 | }, 197 | { 198 | "board": "sample", 199 | "description": "", 200 | "history": [{"start": "2017-06-04T22:29:54.032434", "state": "ready"}], 201 | "id": "idbm", 202 | "order": 17, 203 | "parent": "persistence", 204 | "size": 1, 205 | "title": "IndexedDB/Demo API Move", 206 | "state": "ready" 207 | }, 208 | { 209 | "board": "sample", 210 | "description": "", 211 | "history": [{"start": "2017-06-04T22:29:54.032439", "state": "ready"}], 212 | "id": "sdb", 213 | "order": 18, 214 | "parent": "persistence", 215 | "size": 2, 216 | "title": "Server API Boards", 217 | "state": "ready" 218 | }, 219 | { 220 | "board": "sample", 221 | "description": "", 222 | "history": [{"start": "2017-06-04T22:29:54.032444", "state": "ready"}], 223 | "id": "sdbp", 224 | "order": 19, 225 | "parent": "persistence", 226 | "size": 1, 227 | "title": "Server API Projects and tasks", 228 | "state": "ready" 229 | }, 230 | { 231 | "board": "sample", 232 | "description": "", 233 | "history": [{"start": "2017-06-04T22:29:54.032451", "state": "ready"}], 234 | "id": "sdbm", 235 | "order": 20, 236 | "parent": "persistence", 237 | "size": 1, 238 | "title": "Server API Move", 239 | "state": "ready" 240 | }, 241 | { 242 | "board": "sample", 243 | "description": "", 244 | "history": [{"start": "2017-06-04T22:29:54.032457", "state": "Backlog"}], 245 | "id": "auth", 246 | "order": 21, 247 | "title": "Users and authentication", 248 | "state": "Ready" 249 | }, 250 | { 251 | "board": "sample", 252 | "description": "", 253 | "history": [{"start": "2017-06-04T22:29:54.032463", "state": "ready"}], 254 | "id": "demo_current", 255 | "order": 22, 256 | "parent": "auth", 257 | "size": 1, 258 | "title": "Demo current/logged-in user in Demo", 259 | "state": "ready" 260 | }, 261 | { 262 | "board": "sample", 263 | "description": "", 264 | "history": [{"start": "2017-06-04T22:29:54.032469", "state": "ready"}], 265 | "id": "assignment", 266 | "order": 23, 267 | "parent": "auth", 268 | "size": 2, 269 | "title": "User assignment", 270 | "state": "ready" 271 | }, 272 | { 273 | "board": "sample", 274 | "description": "", 275 | "history": [{"start": "2017-06-04T22:29:54.032485", "state": "ready"}], 276 | "id": "server_auth", 277 | "order": 24, 278 | "parent": "auth", 279 | "size": 2, 280 | "title": "Server Auth framework", 281 | "state": "ready" 282 | }, 283 | { 284 | "board": "sample", 285 | "description": "", 286 | "history": [{"start": "2017-06-04T22:29:54.032491", "state": "ready"}], 287 | "id": "pw_auth", 288 | "order": 25, 289 | "parent": "auth", 290 | "size": 3, 291 | "title": "Server Email/Password auth", 292 | "state": "ready" 293 | }, 294 | { 295 | "board": "sample", 296 | "description": "", 297 | "history": [{"start": "2017-06-04T22:29:54.032497", "state": "ready"}], 298 | "id": "switch_user", 299 | "order": 26, 300 | "parent": "auth", 301 | "size": 2, 302 | "title": "Demo switch user", 303 | "state": "ready" 304 | }, 305 | { 306 | "board": "sample", 307 | "description": "", 308 | "history": [{"start": "2017-06-04T22:29:54.032502", "state": "ready"}], 309 | "id": "demo_profile", 310 | "order": 27, 311 | "parent": "auth", 312 | "size": 2, 313 | "title": "Demo update profile", 314 | "state": "ready" 315 | }, 316 | { 317 | "board": "sample", 318 | "description": "", 319 | "history": [{"start": "2017-06-04T22:29:54.032508", "state": "ready"}], 320 | "id": "pw_logout", 321 | "order": 28, 322 | "parent": "auth", 323 | "size": 1, 324 | "title": "Email/password logout", 325 | "state": "ready" 326 | }, 327 | { 328 | "board": "sample", 329 | "description": "", 330 | "history": [{"start": "2017-06-04T22:29:54.032513", "state": "ready"}], 331 | "id": "pw_profile", 332 | "order": 29, 333 | "parent": "auth", 334 | "size": 2, 335 | "title": "Email/password update profile", 336 | "state": "ready" 337 | }, 338 | { 339 | "board": "sample", 340 | "description": "", 341 | "history": [{"start": "2017-06-04T22:29:54.032519", "state": "Backlog"}], 342 | "id": "vel", 343 | "order": 30, 344 | "title": "Velocity", 345 | "state": "Ready" 346 | }, 347 | { 348 | "board": "sample", 349 | "description": "", 350 | "history": [{"start": "2017-06-04T22:29:54.032526", "state": "ready"}], 351 | "id": "vel_model", 352 | "order": 31, 353 | "parent": "vel", 354 | "size": 1, 355 | "title": "Define model", 356 | "state": "ready" 357 | }, 358 | { 359 | "board": "sample", 360 | "description": "", 361 | "history": [{"start": "2017-06-04T22:29:54.032531", "state": "ready"}], 362 | "id": "vel_ex", 363 | "order": 32, 364 | "parent": "vel", 365 | "size": 2, 366 | "title": "Develop example", 367 | "state": "ready" 368 | }, 369 | { 370 | "board": "sample", 371 | "description": "", 372 | "history": [{"start": "2017-06-04T22:29:54.032537", "state": "ready"}], 373 | "id": "vel_test", 374 | "order": 33, 375 | "parent": "vel", 376 | "size": 1, 377 | "title": "Create test based on example", 378 | "state": "ready" 379 | }, 380 | { 381 | "board": "sample", 382 | "description": "", 383 | "history": [{"start": "2017-06-04T22:29:54.032542", "state": "ready"}], 384 | "id": "vel_model_pass", 385 | "order": 34, 386 | "parent": "vel", 387 | "size": 3, 388 | "title": "demo+model test", 389 | "state": "ready" 390 | }, 391 | { 392 | "board": "sample", 393 | "description": "", 394 | "history": [{"start": "2017-06-04T22:29:54.032851", "state": "ready"}], 395 | "id": "vel_server", 396 | "order": 35, 397 | "parent": "vel", 398 | "size": 1, 399 | "title": "Server API velocity support", 400 | "state": "ready" 401 | }, 402 | { 403 | "board": "sample", 404 | "description": "", 405 | "history": [{"start": "2017-06-04T22:29:54.032870", "state": "ready"}], 406 | "id": "vel_display_design", 407 | "order": 36, 408 | "parent": "vel", 409 | "size": 2, 410 | "title": "Design velocity display", 411 | "state": "ready" 412 | }, 413 | { 414 | "board": "sample", 415 | "description": "", 416 | "history": [{"start": "2017-06-04T22:29:54.032876", "state": "ready"}], 417 | "id": "vel_display ", 418 | "order": 37, 419 | "parent": "vel", 420 | "size": 1, 421 | "title": "Velocity display", 422 | "state": "ready" 423 | } 424 | ] 425 | } 426 | -------------------------------------------------------------------------------- /client/tests/testboard.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | import {Board} from '../model/board'; 3 | 4 | const states = [ 5 | {"id": "Ready", "title": "Ready", "order": 2, "complete": false, 6 | "working": false, explode: false, "task": false }, 7 | {"id": "Backlog", "title": "Backlog", "order": 0, 8 | "complete": false, "working": false, explode: false, "task": false }, 9 | {"id": "Development", "title": "Development", "order": 3, 10 | "complete": false, "working": false, explode: true, "task": false}, 11 | {"id": "ready", "title": "Ready", "task": true, "order": 4, 12 | "complete": false, "working": false, explode: false 13 | }, 14 | {"id": "Needs review", "title": "Needs review", "task": true, "order": 6, 15 | "complete": false, "working": false, explode: false }, 16 | {"id": "Doing", "title": "Doing", "task": true, "order": 5, 17 | "complete": false, "working": true, explode: false }, 18 | {"id": "Done", "title": "Done", "task": true, "order": 7, 19 | "complete": true, "working": false, explode: false}, 20 | {"id": "Deployed", "title": "Deployed", "order": 8, 21 | "complete": false, "working": false, explode: false, "task": false}]; 22 | 23 | const boards = [ 24 | {name: 'test', title: 'Test', description: 'test board'}, 25 | {name: 'test2', title: 'Another', description: 'nother board'}]; 26 | 27 | function task(id, order, data) { 28 | const task = { 29 | "blocked": null, "assigned": null, "created": 1494531763.295133, 30 | "description": "", "complete": null, order: order, 31 | "size": 1, "id": id, "state": null, "title": id, "parent": null, 32 | history: [{}] 33 | }; 34 | if (data) { 35 | Object.assign(task, data); 36 | } 37 | return task; 38 | } 39 | 40 | function initialized_board() { 41 | const board = new Board('test'); 42 | board.update({ 43 | board: {name: 'dev', title: 'Test', description: 'test board'}, 44 | site: {boards: boards, users: []}, 45 | states: {adds: states}}); 46 | return board; 47 | } 48 | 49 | describe("Kanban Board", () => { 50 | 51 | it("Should initialize", () => { 52 | const board = new Board('test'); 53 | expect(board.name).toBe('test'); 54 | expect(board.boards).toEqual([]); 55 | expect(board.users).toEqual([]); 56 | }); 57 | 58 | it("Should handle its own data", () => { 59 | const board = initialized_board(); 60 | expect(board.name).toBe("dev"); 61 | expect(board.title).toBe("Test"); 62 | expect(board.description).toBe("test board"); 63 | expect(board.boards).toEqual(boards); 64 | expect(board.users).toEqual([]); 65 | expect(board.project_states.map((s) => ({id: s.id, title: s.title}))) 66 | .toEqual([ 67 | {"id": "Backlog", "title": "Backlog"}, 68 | {"id": "Ready", "title": "Ready"}, 69 | {"id": "Development", "title": "Development"}, 70 | {"id": "Deployed", "title": "Deployed"} 71 | ]); 72 | expect(board.task_states.map((s) => ({id: s.id, title: s.title}))) 73 | .toEqual([ 74 | {"id": "ready", "title": "Ready"}, 75 | {"id": "Doing", "title": "Doing"}, 76 | {"id": "Needs review", "title": "Needs review"}, 77 | {"id": "Done", "title": "Done"}, 78 | ]); 79 | }); 80 | 81 | it("should handle tasks in states", () => { 82 | const board = initialized_board(); 83 | board.update({tasks: {adds: [ 84 | task('t1', 1, {parent: 'p1'}), 85 | task('t2', 2, {parent: 'p1', state: 'ready'}), 86 | task('t3', 3, {parent: 'p1', state: 'Doing'}), 87 | task('p1', 4), 88 | task('p2', 5, {state: 'Development'}) 89 | ]}}); 90 | 91 | expect(board.subtasks('Backlog').map((t) => t.id)).toEqual(['p1']); 92 | expect(board.subtasks('Development').map((t) => t.id)).toEqual(['p2']); 93 | expect(board.subtasks('Ready')).toEqual([]); 94 | expect(board.subtasks('Deployed')).toEqual([]); 95 | 96 | expect(board.tasks['p1'].subtasks('ready').map((t) => t.id)) 97 | .toEqual(['t1', 't2']); 98 | expect(board.tasks['p1'].subtasks('Doing').map((t) => t.id)) 99 | .toEqual(['t3']); 100 | expect(board.tasks['p1'].subtasks('Needs review')).toEqual([]); 101 | expect(board.tasks['p1'].subtasks('Done')).toEqual([]); 102 | 103 | // Now move some things around 104 | board.update({tasks: {adds: [ 105 | task('p1', 1, {state: 'Development'}), 106 | task('t1', 3, {parent: 'p1', state: 'Doing'}), 107 | task('t2', 2, {parent: 'p1', state: 'Doing'}), 108 | task('t3', 1, {parent: 'p1', state: 'Doing'}), 109 | task('p2', 0, {parent: 'p1', state: 'Doing'}) 110 | ]}}); 111 | expect(board.subtasks('Backlog')).toEqual([]); 112 | expect(board.subtasks('Development').map((t) => t.id)).toEqual(['p1']); 113 | expect(board.subtasks('Ready')).toEqual([]); 114 | expect(board.subtasks('Deployed')).toEqual([]); 115 | 116 | expect(board.tasks['p1'].subtasks('ready')).toEqual([]); 117 | expect(board.tasks['p1'].subtasks('Needs review')).toEqual([]); 118 | expect(board.tasks['p1'].subtasks('Done')).toEqual([]); 119 | expect(board.tasks['p1'].subtasks('Doing').map((t) => t.id)) 120 | .toEqual(['p2', 't3', 't2', 't1']); 121 | }); 122 | 123 | it("should compute stats on update", () => { 124 | const board = initialized_board(); 125 | board.update({tasks: {adds: [ 126 | task('t1', 1, {parent: 'p1', size: 1, history: [{complete: true}]}), 127 | task('t2', 2, {parent: 'p1', size: 2, history: [{complete: true}]}), 128 | task('t3', 3, {parent: 'p1', size: 3}), 129 | task('p1', 4), 130 | task('p2', 5, {state: 'Development'}) 131 | ]}}); 132 | expect(board.tasks['p1'].count).toBe(3); 133 | expect(board.tasks['p1'].total_size).toBe(6); 134 | expect(board.tasks['p1'].total_completed).toBe(3); 135 | expect(board.tasks['p2'].count).toBe(0); 136 | expect(board.tasks['p2'].total_size).toBe(0); 137 | expect(board.tasks['p2'].total_completed).toBe(0); 138 | }); 139 | 140 | it("should update user on existing task", () => { 141 | const board = initialized_board(); 142 | board.update( 143 | { 144 | tasks: {adds: [ 145 | task('t1', 1, {parent: 'p1', size: 1, history: [{complete: true}]}), 146 | task('p1', 4), 147 | ]}, 148 | site: { 149 | users: [{"id": "gal", "nick": "gal", "email": "gal@example.com", 150 | "name": "Gal Humana"}] 151 | } 152 | }); 153 | expect(board.tasks['t1'].user).toBe(undefined); 154 | board.update({tasks: {adds: [ 155 | task('t1', 1, 156 | {assigned: 'gal', 157 | parent: 'p1', size: 1, history: [{complete: true}]}), 158 | ]}}); 159 | expect(board.tasks['t1'].user.id).toBe('gal'); 160 | expect(board.tasks['t1'].user.nick).toBe('gal'); 161 | expect(board.tasks['t1'].user.name).toBe('Gal Humana'); 162 | expect(board.tasks['t1'].user.email).toBe('gal@example.com'); 163 | }); 164 | 165 | it("should handle removals", () => { 166 | const board = initialized_board(); 167 | board.update({tasks: {adds: [ 168 | task('t1', 1, {parent: 'p1'}), 169 | task('t2', 2, {parent: 'p1', state: 'ready'}), 170 | task('t3', 3, {parent: 'p1', state: 'Doing'}), 171 | task('p1', 4), 172 | task('p2', 5, {state: 'Development'}) 173 | ]}}); 174 | 175 | board.update({tasks: {removals: ['t1', 't2']}}); 176 | expect(board.all_tasks.map((t) => t.id)).toEqual(['t3', 'p1', 'p2']); 177 | expect(board.tasks['p1'].subtasks('ready').map((t) => t.id)).toEqual([]); 178 | expect(board.tasks['p1'].count).toBe(1); 179 | 180 | board.update({tasks: {removals: ['t3', 'p1']}}); 181 | expect(board.subtasks('Backlog').map((t) => t.id)).toEqual([]); 182 | expect(board.all_tasks.map((t) => t.id)).toEqual(['p2']); 183 | }); 184 | 185 | it("should have an archive count", () => { 186 | const board = initialized_board(); 187 | expect(board.archive_count).toBe(0); 188 | board.update({board: {archive_count: 1}}); 189 | expect(board.archive_count).toBe(1); 190 | }); 191 | 192 | it("should handle having task contents replaces", () => { 193 | const board = initialized_board(); 194 | board.update({tasks: {adds: [ 195 | task('xt1', 1, {parent: 'xp2'}), 196 | task('xt2', 2, {parent: 'xp2', state: 'ready'}), 197 | task('xt3', 3, {parent: 'xp1', state: 'Doing'}), 198 | task('xp1', 4), 199 | task('xp2', 5, {state: 'Development'}) 200 | ]}}); 201 | board.update({tasks: {contents: [ 202 | task('t3', 3, {parent: 'p1', state: 'Doing'}), 203 | task('p1', 4), 204 | task('p2', 5, {state: 'Development'}) 205 | ]}}); 206 | expect(board.all_tasks.map((t) => t.id)).toEqual(['t3', 'p1', 'p2']); 207 | expect(board.tasks['p1'].subtasks('ready').map((t) => t.id)).toEqual([]); 208 | expect(board.tasks['p1'].count).toBe(1); 209 | board.update({tasks: {removals: ['t3', 'p1']}}); 210 | expect(board.subtasks('Backlog').map((t) => t.id)).toEqual([]); 211 | expect(board.all_tasks.map((t) => t.id)).toEqual(['p2']); 212 | }); 213 | 214 | }); 215 | -------------------------------------------------------------------------------- /client/tests/testdemositeapi.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | import indexedDB from 'indexedDB'; 3 | 4 | import {SiteAPI} from '../demo/siteapi'; 5 | 6 | describe("demo site api", () => { 7 | 8 | afterEach("Clean up database", (done) => { 9 | SiteAPI.test_reset(done); 10 | }); 11 | 12 | it("should set up initial state", (done) => { 13 | const view = {setState: expect.createSpy()}; 14 | new SiteAPI(view, (api) => { 15 | const model = api.model; 16 | expect(view.setState).toHaveBeenCalledWith({model: model}); 17 | expect(model.boards).toEqual([ 18 | { "description": 19 | "This sample board provides an example board with sample" + 20 | " projects and tasks", 21 | "name": "sample", "title": "Sample board" }]); 22 | expect(model.user).toEqual( 23 | {id: "ryou", nick: "ryou", email: "ryou@example.com", 24 | name: "Ryou Bosso", admin: true, current: true}); 25 | expect(model.users).toEqual([ 26 | {"id": "alex", "nick": "alex", "email": "alex@example.com", 27 | "name": "Alex Peeple"}, 28 | {"id": "cas", "nick": "cas", "email": "cas@example.com", 29 | "name": "Cas Emplo"}, 30 | {"id": "gal", "nick": "gal", "email": "gal@example.com", 31 | "name": "Gal Humana"}, 32 | {"id": "jaci", "nick": "jaci", "email": "jaci@example.com", 33 | "name": "Jaci Admi", "admin": true}, 34 | {"id": "kiran", "nick": "kiran", "email": "kiran@example.com", 35 | "name": "Kiran Persons"}, 36 | {"id": "ryou", "nick": "ryou", "email": "ryou@example.com", 37 | "name": "Ryou Bosso", "admin": true, "current": true} 38 | ]); 39 | done(); 40 | }); 41 | }); 42 | 43 | it("should add boards with a name", (done) => { 44 | const view = {setState: expect.createSpy()}; 45 | new SiteAPI(view, (api) => { 46 | api.add_board('test', () => { 47 | view.setState.restore(); 48 | api.add_board('test2', () => { 49 | const model = api.model; 50 | expect(view.setState).toHaveBeenCalledWith({model: model}); 51 | expect(model.boards) 52 | .toEqual([ 53 | { "description": 54 | "This sample board provides an example board with sample" + 55 | " projects and tasks", 56 | "name": "sample", "title": "Sample board" }, 57 | {name: 'test', title: '', description: ''}, 58 | {name: 'test2', title: '', description: ''} 59 | ]); 60 | done(); 61 | }); 62 | }); 63 | }); 64 | }); 65 | 66 | describe("User-management common to site and boards", () => { 67 | 68 | it("should switch users", (done) => { 69 | 70 | const view = {setState: expect.createSpy()}; 71 | new SiteAPI(view, (api) => { 72 | const model = api.model; 73 | expect(api.user.id).toBe('ryou'); 74 | expect(model.user.id).toBe('ryou'); 75 | api.switch_user('cas', () => { 76 | expect(api.user.id).toBe('cas'); 77 | expect(model.user.id).toBe('cas'); 78 | api.transaction('users', 'readonly', (trans) => { 79 | api.users(trans, () => { 80 | expect(api.user.id).toBe('cas'); 81 | trans.oncomplete = () => done(); 82 | }); 83 | }); 84 | }); 85 | }); 86 | }); 87 | 88 | it("should update user's profile", (done) => { 89 | 90 | const view = {setState: expect.createSpy()}; 91 | new SiteAPI(view, (api) => { 92 | const model = api.model; 93 | expect(api.user.id).toBe('ryou'); 94 | expect(model.user.id).toBe('ryou'); 95 | api.update_profile( 96 | {"id": "ryou", "nick": "test", "email": "test@example.com", 97 | "name": "Test"}, () => { 98 | expect(api.user).toEqual( 99 | {"id": "ryou", "nick": "test", "email": "test@example.com", 100 | "name": "Test", "admin": true, "current": true} 101 | ); 102 | expect(api.model.users.filter((u) => u.id === api.user.id)) 103 | .toEqual([api.user]); // Make sure model users are updated 104 | api.transaction('users', 'readonly', (trans) => { 105 | api.users(trans, () => { 106 | expect(api.user).toEqual( 107 | {"id": "ryou", "nick": "test", "email": "test@example.com", 108 | "name": "Test", "admin": true, "current": true} 109 | ); 110 | expect(api.model.users.filter((u) => u.id === api.user.id)) 111 | .toEqual([api.user]); // Make sure model users are updated 112 | trans.oncomplete = () => done(); 113 | }); 114 | }); 115 | }); 116 | }); 117 | }); 118 | 119 | it("should update user type", (done) => { 120 | const view = {setState: expect.createSpy()}; 121 | new SiteAPI(view, (api) => { 122 | const model = api.model; 123 | api.change_user_type('gal', true, (_, updates) => { 124 | expect(updates.site.users.filter((u) => u.id == 'gal')[0].admin) 125 | .toBe(true); 126 | done(); 127 | }); 128 | }); 129 | }); 130 | 131 | it("should add users", (done) => { 132 | const view = {setState: expect.createSpy()}; 133 | new SiteAPI(view, (api) => { 134 | const model = api.model; 135 | expect(model.users.length).toBe(6); 136 | api.add_user( 137 | 'test@example.com', 'Testy Tester', false, (_, updates) => { 138 | expect(updates.site.users.length).toBe(7); 139 | const [user] = updates.site.users.filter( 140 | (u) => u.email == 'test@example.com'); 141 | delete user.id; 142 | expect(user).toEqual({ 143 | email: 'test@example.com', 144 | name: 'Testy Tester', 145 | admin: false, 146 | nick: '' 147 | }); 148 | api.add_user( 149 | 'test2@example.com', '', true, (_, updates) => { 150 | expect(updates.site.users.length).toBe(8); 151 | const [user] = updates.site.users.filter( 152 | (u) => u.email == 'test2@example.com'); 153 | delete user.id; 154 | expect(user).toEqual({ 155 | email: 'test2@example.com', 156 | name: '', 157 | admin: true, 158 | nick: '' 159 | }); 160 | done(); 161 | }); 162 | }); 163 | }); 164 | }); 165 | 166 | }); 167 | }); 168 | -------------------------------------------------------------------------------- /client/ui/admin.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export class Admin extends React.Component { 4 | 5 | render() { 6 | if (this.props.user.admin) { 7 | return
{this.props.children}
; 8 | } 9 | else { 10 | return null; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /client/ui/app.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export class Base extends React.Component { 4 | 5 | constructor(props) { 6 | super(props); 7 | this.active = false; 8 | this.visibility_changed = () => { 9 | if (document.hidden) { 10 | console.log('hidden', new Date()); 11 | this.api.stop(); 12 | } 13 | else if (this.active) { 14 | console.log('unhidden', new Date()); 15 | this.api.start(); 16 | } 17 | }; 18 | this.api = this.new_api(props); 19 | this.state = {model: this.api.model}; 20 | } 21 | 22 | componentWillUnmount() { 23 | this.active = false; 24 | document.removeEventListener("visibilitychange", this.visibility_changed); 25 | this.api.stop(); 26 | } 27 | 28 | componentWillMount() { 29 | this.active = true; 30 | document.addEventListener("visibilitychange", this.visibility_changed); 31 | this.visibility_changed(); 32 | } 33 | 34 | componentWillReceiveProps(nextProps) { 35 | if (nextProps.params.name !== this.props.params.name) { 36 | this.api.stop(); 37 | this.api = this.new_api(nextProps); 38 | this.setState({model: this.api.model}); 39 | if (! document.hidden) { 40 | this.api.start(); 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /client/ui/board.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classes from 'classnames'; 3 | 4 | import {AddProject} from './project'; 5 | import {Base} from './app'; 6 | import {BoardAPI} from 'BoardAPI'; 7 | import {Dialog, DialogBase, Input} from './dialog'; 8 | import {Draggable, DropZone} from './dnd'; 9 | import {Frame} from './frame'; 10 | import {TheBag} from './thebag'; 11 | import {TooltipButton, TooltipIconButton} from './util'; 12 | import {Project} from './project'; 13 | 14 | export class Board extends Base { 15 | 16 | new_api(props) { 17 | return new BoardAPI(this, props.params.name); 18 | } 19 | 20 | show_rename() { 21 | const name = this.state.model.name; 22 | this.refs.rename.show({old_name: name, name: name}); 23 | } 24 | 25 | download() { 26 | this.api.export_url((url) => { 27 | window.open(url, '_blank'); 28 | }); 29 | } 30 | 31 | render() { 32 | const board = this.state.model; 33 | if (board.NotFound) { 34 | window.location.hash = '#/'; 35 | } 36 | if (this.props.params.name != board.name) { 37 | window.location.hash = '#/board/' + encodeURIComponent(board.name); 38 | } 39 | document.title = board.name; 40 | 41 | const extra_nav = board.user.admin ? [ 42 | ( this.download()} 45 | />), 46 | ( this.show_rename()} 49 | />) 50 | ] : null; 51 | 52 | return ( 53 |
54 | 61 | 62 | this.api.rename(name)} /> 63 |
); 64 | } 65 | } 66 | 67 | class Rename extends DialogBase { 68 | 69 | render() { 70 | 71 | return ( 72 | this.props.rename(this.state.name)}> 75 | 77 | 78 | ); 79 | } 80 | } 81 | 82 | 83 | class Projects extends React.Component { 84 | 85 | render() { 86 | const {board} = this.props; 87 | const states = board.project_states; 88 | 89 | if (states.length == 0) { 90 | return
; 91 | } 92 | 93 | const columns = () => { 94 | return states.slice(1).map((state) => { 95 | return ( 96 | 103 | ); 104 | }); 105 | }; 106 | 107 | const size = states.slice(1).reduce( 108 | (n, state) => n + board.subtasks(state.id).length, 109 | 0 110 | ); 111 | 112 | return ( 113 |
114 |
115 | {columns()} 116 |
117 | 118 |
119 |
120 | 126 | this.refs.add.show()} 128 | tooltip="Add a new feature to the backlog." 129 | tooltipPosition="right" 130 | /> 131 | 132 |
133 | 135 |
136 |
137 | ); 138 | } 139 | } 140 | 141 | 142 | class ProjectColumn extends React.Component { 143 | 144 | dropped(feature_id, before_id) { 145 | const {api, board, state} = this.props; 146 | api.move( 147 | feature_id, 148 | undefined, // id of destination project 149 | this.props.state.id, // destination state id 150 | before_id); // move before project with before_id (optional) 151 | 152 | if (state.explode) { 153 | const feature = board.tasks[feature_id]; 154 | if (feature.subtasks().length == 0) { 155 | api.add_task({ 156 | project_id: feature_id, 157 | title: ' ', 158 | description: '', 159 | size: 1}); 160 | } 161 | } 162 | } 163 | 164 | projects() { 165 | const result = []; 166 | const {board, api} = this.props; 167 | 168 | this.props.projects.forEach((project) => { 169 | const dropped = (data) => this.dropped(data.id, project.id); 170 | 171 | result.push( 172 | 174 | ); 175 | result.push( 176 | 180 | 181 | 182 | ); 183 | }); 184 | 185 | const {projects} = this.props; 186 | const disallow = projects.length > 0 ? [projects.slice(-1)[0].id] : []; 187 | result.push( 188 | this.dropped(data.id)} 190 | disallow={disallow} 191 | /> 192 | ); 193 | 194 | return result; 195 | } 196 | 197 | render() { 198 | return ( 199 |
200 |

{this.props.state.title}

201 | {this.projects()} 202 |
203 | ); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /client/ui/dialog.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Dialog helpers 4 | 5 | The react-toolbox Dialog component and input compnents (input and 6 | dropdown) are pretty good at displaying dialogs and inputs, but they 7 | don't manage data very well. The components defined here help with 8 | that. There's also an Editor input that wraps the RichTextEditor 9 | component from react-rte. 10 | 11 | I suggest looking at some examples as you read this. 12 | 13 | To create a data-input (or edit) dialog you: 14 | 15 | - Subclass DialogBase. 16 | 17 | In your render method, you return a Dialog (the one defined here) 18 | component around some input components (and whatever other 19 | components you want). 20 | 21 | The Dialog component must be given properties: 22 | 23 | - ref="dialog" 24 | 25 | You must supply this prop and it must have this value. This is 26 | needed to make automation for displaying dislogs work. Hopefully, 27 | there's a way around this that I just haven't found yet. 28 | 29 | - title="YOUR TITLE" 30 | 31 | - action="YOUR OK ACTION" 32 | 33 | This is what's displayed on the OK button. It defaults to "Ok". 34 | 35 | - finish() 36 | 37 | A callback function that's called when user clicks OK and there 38 | aren't validation errors. This function isn't called with 39 | data. The input data is in ``this.state``. 40 | 41 | - type (see react-toolbox docs) 42 | 43 | - On your input components, supply an ``onChange`` property that is 44 | the result of calling ``this.val('NAME')``. 45 | 46 | For example: 47 | 48 | 77 | 78 | 79 | - Render an instance of your custom dialog component somewhere. Give 80 | it a ``ref`` ref you can use to fetch it when you want to display 81 | the dialog (e.g. in an onClick handler). To display the dialog, 82 | call it's ``show()`` method. You may pass an object containing 83 | initial state. 84 | 85 | */ 86 | 87 | import React from 'react'; 88 | import RTDialog from 'react-toolbox/lib/dialog'; 89 | import Dropdown from 'react-toolbox/lib/dropdown'; 90 | import RTInput from 'react-toolbox/lib/input'; 91 | import RichTextEditor from 'react-rte'; 92 | 93 | export class Dialog extends React.Component { 94 | 95 | constructor() { 96 | super(); 97 | this.state = { active: false }; 98 | } 99 | 100 | show() { 101 | this.setState({active: true}); 102 | } 103 | 104 | hide() { 105 | this.setState({active: false}); 106 | } 107 | 108 | validate() { 109 | let valid = true; 110 | React.Children.forEach(this.props.children, (c) => { 111 | if (c.props && c.props.onChange && c.props.onChange.validate) { 112 | if (! c.props.onChange.validate(c.props.onChange)) { 113 | valid = false; 114 | }; 115 | } 116 | }); 117 | return valid; 118 | } 119 | 120 | finish() { 121 | if (this.validate()) { 122 | if (! this.props.finish()) { 123 | this.hide(); 124 | } 125 | } 126 | } 127 | 128 | render() { 129 | const {action, extra_actions, children, title, type} = this.props; 130 | const cancel = () => this.hide(); 131 | const actions = [{label: "Cancel (esc)", onClick: cancel}, 132 | {label: action || "Ok", 133 | onClick: () => this.finish() 134 | }] 135 | .concat(extra_actions || []); 136 | 137 | return ( 138 | 147 | {children} 148 | 149 | ); 150 | } 151 | } 152 | 153 | export class Input extends React.Component { 154 | 155 | focus() { 156 | this.input.focus(); 157 | } 158 | 159 | on_key_press(ev) { 160 | if (ev.key == "Enter" && this.props.onEnter) { 161 | this.props.onEnter(); 162 | } 163 | } 164 | 165 | render() { 166 | const props = Object.assign({}, this.props); 167 | delete props.onEnter; 168 | 169 | return ( 170 | {this.input = c;}} 175 | onKeyPress={(ev) => this.on_key_press(ev)} 176 | /> 177 | ); 178 | } 179 | } 180 | 181 | export class Editor extends React.Component { 182 | 183 | render () { 184 | return ( 185 | 189 | ); 190 | } 191 | } 192 | 193 | export class Select extends React.Component { 194 | 195 | render() { 196 | const source = this.props.source.map( 197 | (s) => (typeof s === 'object' ? s : {label: s, value: s}) 198 | ); 199 | 200 | return ( 201 | 204 | ); 205 | } 206 | } 207 | 208 | const validate_required = 209 | (v, name) => v ? null : "Please provide a value for " + name + "."; 210 | 211 | 212 | export class DialogBase extends React.Component { 213 | 214 | constructor() { 215 | super(); 216 | this.state = {_DialogBase_validations: 0}; 217 | this.errors = {}; 218 | } 219 | 220 | validated() { 221 | this.setState( 222 | {_DialogBase_validations: this.state._DialogBase_validations + 1}); 223 | } 224 | 225 | val(name, default_='', validate=undefined) { 226 | const onChange = (v) => { 227 | if (v == undefined) { 228 | return this.state[name] || default_; 229 | } 230 | else { 231 | const state = {}; 232 | state[name] = v; 233 | if (validate) { 234 | this.errors[name] = validate(v); 235 | } 236 | return this.setState(state); 237 | } 238 | }; 239 | 240 | onChange.error = () => { 241 | return this.errors[name]; 242 | }; 243 | 244 | if (validate) { 245 | onChange.validate = (v) => { 246 | this.validated(); // force render 247 | this.errors[name] = validate(onChange(), name, this); 248 | return ! this.errors[name]; 249 | }; 250 | } 251 | return onChange; 252 | } 253 | 254 | required(name) { 255 | return this.val(name, '', validate_required); 256 | } 257 | 258 | show(state) { 259 | this.should_focus = true; 260 | this.errors = {}; 261 | this.state = {}; 262 | if (state) { 263 | this.setState(state); 264 | } 265 | else { 266 | this.setState(this.state); 267 | } 268 | this.refs.dialog.show(); 269 | } 270 | 271 | componentDidUpdate() { 272 | if (this.refs.focus && this.should_focus) { 273 | this.refs.focus.focus(); 274 | this.should_focus = false; 275 | } 276 | } 277 | } 278 | 279 | export const show_dialog = (dialog, state) => () => dialog.show(state); 280 | 281 | export class Confirm extends DialogBase { 282 | render() { 283 | return ( 284 | 291 | {this.props.text} 292 | 293 | ); 294 | } 295 | } 296 | 297 | export const large = 298 | navigator.userAgent.indexOf('Safari/') >= 0 && 299 | navigator.userAgent.indexOf('Chrome/') < 0 && 300 | navigator.userAgent.indexOf('Chromium/') < 0 301 | ? 'normal' : 'large'; 302 | 303 | -------------------------------------------------------------------------------- /client/ui/dnd.jsx: -------------------------------------------------------------------------------- 1 | 2 | /* HTML5-based Drag and Drop supprt 3 | 4 | Draggable components have a data prop containing drag data. 5 | 6 | DropZone components have props: 7 | 8 | dropped 9 | Callable called with drag data on a drop 10 | 11 | disallow 12 | List of data ids or types that can't be dropped. 13 | 14 | (This is a little warty. Maybe it would be better to move this 15 | abstraction out and just have an allowed property giving a guard 16 | function.) 17 | 18 | */ 19 | 20 | /* A Note on the weirdness of this implementation and HTML5 drag and drop. 21 | 22 | First, I considered using react-dnd, but it seems overly complex. 23 | I'm pretty happy with the simplicity of use of this 24 | implementation. Perhaps I'll change my mind later, especially to 25 | get support for mobile. 26 | 27 | The HTML5 drag and drop spec provides a dataTransfer object to hold 28 | drag-related data, but it doesn't make that data available when 29 | dragging over. This means that data aren't available to decide if 30 | drags are legal. Weirdly, type/format names *are* available. A 31 | common hack is to imbed data in type names, as in: text/1234 to save 32 | the id 1234. Unfortunately, this doesn't work in Edge and IE because 33 | they are more restrictive about type names. 34 | 35 | Apparently, the expected way to deal with this is to use a global 36 | variable to hold data, because TCBOO drag at once. (The spec was 37 | based on a MS implementation that assumed only one drag can happen 38 | at a time.) 39 | 40 | */ 41 | 42 | import React from 'react'; 43 | import classes from 'classnames'; 44 | 45 | let data; // There can be only one and the HTML5 drag-and-drop spec is dumb. 46 | 47 | export class Draggable extends React.Component { 48 | 49 | constructor(props) { 50 | super(props); 51 | this.state = {class_name: ''}; 52 | } 53 | 54 | render() { 55 | 56 | const dragstart = (ev) => { 57 | if (! (ev.dataTransfer.types && ev.dataTransfer.types.length)) { 58 | data = this.props.data; 59 | this.setState({class_name: 'dragging'}); 60 | // Needed to prevent container drag data overriding contained drag data 61 | ev.dataTransfer.setData('text/plain', ''); 62 | } 63 | }; 64 | const dragend = () => this.setState({class_name: ''}); 65 | 66 | return ( 67 |
72 | {this.props.children} 73 |
74 | ); 75 | } 76 | } 77 | 78 | export class DropZone extends React.Component { 79 | 80 | constructor(props) { 81 | super(props); 82 | this.dragin = 0; 83 | this.state = {dragover: false}; 84 | } 85 | 86 | allowed(ev) { 87 | return data && 88 | this.props.disallow.filter((t) => t == data.id || t == data.type) 89 | .length == 0; 90 | } 91 | 92 | dragover(ev) { 93 | if (this.allowed(ev)) { 94 | ev.preventDefault(); 95 | return false; 96 | } 97 | else { 98 | return true; 99 | }; 100 | } 101 | 102 | dragenter(ev) { 103 | if (this.allowed(ev)) { 104 | if (this.dragin < 1) { 105 | this.dragin = 1; 106 | this.setState({dragover: true}); 107 | } 108 | else { 109 | this.dragin += 1; 110 | } 111 | } 112 | } 113 | 114 | dragleave() { 115 | this.dragin -= 1; 116 | if (this.dragin == 0) { 117 | this.setState({dragover: false}); 118 | } 119 | } 120 | 121 | drop(ev) { 122 | ev.preventDefault(); 123 | this.dragleave(); 124 | this.props.dropped(data); 125 | data = undefined; 126 | } 127 | 128 | render() { 129 | const className = classes( 130 | this.props.className, 131 | {dragover: this.state.dragover}); 132 | 133 | return ( 134 |
this.dragenter(ev)} 136 | onDragLeave={(ev) => this.dragleave(ev)} 137 | onDragOver={(ev) => this.dragover(ev)} 138 | onDrop={(ev) => this.drop(ev)} 139 | > 140 | {this.props.children} 141 |
142 | ); 143 | } 144 | } 145 | 146 | DropZone.defaultProps = {disallow: []}; 147 | -------------------------------------------------------------------------------- /client/ui/frame.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import AppBar from 'react-toolbox/lib/app_bar'; 4 | import {Button} from 'react-toolbox/lib/button'; 5 | import Drawer from 'react-toolbox/lib/drawer'; 6 | import {List, ListItem, ListSubHeader} from 'react-toolbox/lib/list'; 7 | import Navigation from 'react-toolbox/lib/navigation'; 8 | import {MenuItem} from 'react-toolbox/lib/menu'; 9 | import ProgressBar from 'react-toolbox/lib/progress_bar'; 10 | 11 | import version from '../version'; 12 | 13 | import {Admin} from './admin'; 14 | import {Avatar} from 'AuthUI'; 15 | import {Dialog, Input, DialogBase} from './dialog'; 16 | import {TooltipButton, TooltipIconButton, TooltipIconMenu} from './util'; 17 | 18 | const send_us_email = () => { 19 | window.open("mailto:feedback@valuenator.com", "_blank"); 20 | }; 21 | 22 | const report_bug = () => { 23 | window.open("https://github.com/feature-flow/twotieredkanban/issues/new", 24 | "_blank"); 25 | }; 26 | 27 | const get_help = () => { 28 | window.open( 29 | "http://feature-flow.readthedocs.io/en/latest/" + 30 | "valuenator.html#using-valuenator", 31 | "_blank"); 32 | }; 33 | 34 | export class Frame extends React.Component { 35 | 36 | constructor(props) { 37 | super(props); 38 | this.state = { show_drawer: false }; 39 | } 40 | 41 | go_home() { 42 | window.location.hash = '#/'; 43 | } 44 | 45 | render() { 46 | const {api, calls, extra_nav, model, title} = this.props; 47 | 48 | const toggle_drawer = () => { 49 | this.setState({show_drawer: ! this.state.show_drawer}); 50 | }; 51 | 52 | const progress = calls ? ( 53 | 55 | ) : null; 56 | 57 | return ( 58 |
59 | {progress} 60 | 61 | 62 | 67 | 69 | 74 | 79 | 80 | {extra_nav} 81 | 82 | 83 | 84 | 87 | 88 |
89 | 90 | window.location.hash = '#/admin'} 93 | tooltip="Administrative functions" tooltipPosition="right" 94 | /> 95 | this.refs.add.show()} 98 | tooltip="Add another board." tooltipPosition="right" 99 | /> 100 | 101 | 102 | window.location.hash = '#/'} 104 | tooltip="View the welcome message." tooltipPosition="right" 105 | /> 106 |
107 |
{version}
108 |
109 |
110 | ); 111 | } 112 | }; 113 | 114 | const Boards = (props) => { 115 | const goto_board = (board) => { 116 | window.location.hash = '#/board/' + encodeURIComponent(board.name); 117 | }; 118 | 119 | const boards = () => { 120 | const boards = props.boards; 121 | if (boards.length > 0) { 122 | return boards.map((board) => { 123 | return ( 124 | goto_board(board)} 127 | key={board.name} 128 | /> 129 | ); 130 | }); 131 | } 132 | else { 133 | return ; 134 | } 135 | }; 136 | 137 | return ( 138 | 139 | 140 | {boards()} 141 | 142 | ); 143 | 144 | }; 145 | 146 | class AddBoardDialog extends DialogBase { 147 | render() { 148 | return ( 149 | this.props.api.add_board(this.state.name)} 152 | > 153 | 155 | 156 | ); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /client/ui/intro.html: -------------------------------------------------------------------------------- 1 |
2 |

Welcome to Valuenator

3 | 4 |
5 |

Concepts

6 |

The goal of feature flow is to provide value as quickly as 7 | possible. Examples of value include enabling a customer to do something 8 | new, or more easily, increasing efficiency, or learning something 9 | about a customer or market

10 |
11 |
Boards
12 |

A board displays the features a team is 13 | working on. You can navigate to a board by clicking on it's 14 | name in the board menu, which you can view by clicking on 15 | the menu icon in the upper-left corner of the window. (You 16 | can get back here by clicking on the home icon in the 17 | menu.)

18 |

If you're an administrator, you can add a 19 | board from this menu.

20 |
21 |
Features
22 |

Features are the focus of team work.

23 |

At a high level, a board shows features, in 24 | the backlog, or in one of the feature states

25 |
26 |
Tasks
27 |
Features are implemented by one or more tasks.
28 |
29 |
30 |
31 |

Using

32 |

Drag feature and task cards between states.

33 |

Click on plus buttons to add features or tasks.

34 |

Click on pencil buttons to edit features.

35 |

Click on tasks to edit them.

36 |
37 |
38 | -------------------------------------------------------------------------------- /client/ui/intro.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import intro_html from './intro.html'; 4 | 5 | const inner_html = {__html: intro_html}; 6 | 7 | export default (props) => ( 8 |
9 |
10 | ); 11 | -------------------------------------------------------------------------------- /client/ui/project.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Card, CardActions, CardText} from 'react-toolbox/lib/card'; 3 | import RichTextEditor from 'react-rte'; 4 | 5 | import {has_text} from '../model/hastext'; 6 | 7 | import {Confirm, Dialog, DialogBase, Input, Editor, large} from './dialog'; 8 | import {Reveal, Revealable, RevealButton} from './revealbutton'; 9 | import {AddTask, Task, TaskBoard, TaskColumn} from './tasks'; 10 | import {TooltipIconButton} from './util'; 11 | 12 | class ProjectDialog extends DialogBase { 13 | 14 | render() { 15 | const action = this.action(); 16 | 17 | return ( 18 | this.finish(this.state)} type={large}> 20 | 22 | 23 | 24 | ); 25 | } 26 | 27 | } 28 | 29 | export class AddProject extends ProjectDialog { 30 | 31 | action() { return "Add"; } 32 | 33 | show() { 34 | super.show({description: RichTextEditor.createEmptyValue()}); 35 | } 36 | 37 | finish(data) { 38 | this.props.api.add_project({ 39 | title: data.title, 40 | description: data.description.toString('html') 41 | }); 42 | } 43 | 44 | } 45 | 46 | class EditProject extends ProjectDialog { 47 | 48 | action() { return "Edit"; } 49 | 50 | show() { 51 | const project = this.props.project; 52 | super.show({ 53 | id: project.id, 54 | title: project.title, 55 | description: 56 | RichTextEditor.createValueFromString(project.description, 'html') 57 | }); 58 | } 59 | 60 | finish(data) { 61 | this.props.api.update_task(data.id, { 62 | title: data.title, 63 | description: data.description.toString('html') 64 | }); 65 | } 66 | 67 | } 68 | 69 | export class Project extends Revealable { 70 | 71 | shouldComponentUpdate(nextProps, nextState) { 72 | return (nextProps.project.rev !== this.rev || 73 | nextState.expanded !== this.state.expanded); 74 | } 75 | 76 | details() { 77 | if (has_text(this.props.project.description)) { 78 | return ( 79 | 82 | ); 83 | } 84 | return null; 85 | } 86 | 87 | actions() { 88 | const {project, api, board} = this.props; 89 | return ( 90 | 91 | this.refs.delete.show({title: project.title}) 95 | } 96 | tooltip="Delete this feature." tooltipPosition="right" 97 | /> 98 | 100 |

101 | Are you sure you want to delete {project.title}? 102 |

103 |

104 | This cannot be undone. Consider putting this 105 | feature in The Bag instead. 106 |

107 | )} 108 | finish={() => api.remove(project.id)} 109 | /> 110 | this.refs.add.show() } 113 | tooltip="Add a task to this feature." tooltipPosition="right" 114 | /> 115 | 116 | this.refs.edit.show()} 119 | tooltip="Edit this feature." tooltipPosition="right" 120 | /> 121 | 122 |
123 | ); 124 | return null; 125 | } 126 | 127 | tasks() { 128 | const {api, board, project} = this.props; 129 | 130 | if (this.props.project.state.explode) { 131 | return ; 132 | } 133 | if (this.state.expanded) { 134 | return ( 135 | 142 | ); 143 | } 144 | return null; 145 | } 146 | 147 | render () { 148 | const {project} = this.props; 149 | this.rev = project.rev; 150 | 151 | return ( 152 | 153 | 154 |

155 | {project.title} [{project.total_completed}/{project.total_size}] 156 |

157 | 161 |
162 | 163 | {this.details()} 164 | {this.actions()} 165 | 166 | {this.tasks()} 167 |
); 168 | } 169 | 170 | } 171 | -------------------------------------------------------------------------------- /client/ui/revealbutton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {IconButton} from 'react-toolbox/lib/button'; 3 | 4 | export const RevealButton = (props) => { 5 | return ( 6 | ); 10 | }; 11 | 12 | export class Revealable extends React.Component { 13 | 14 | constructor (props) { 15 | super(props); 16 | this.state = {expanded: false}; 17 | } 18 | 19 | toggle_explanded() { 20 | this.setState({expanded: ! this.state.expanded}); 21 | } 22 | } 23 | 24 | export class Reveal extends React.Component { 25 | render() { 26 | if (this.props.expanded) { 27 | return
{this.props.children}
; 28 | } 29 | return null; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/ui/search.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {TooltipIconButton} from './util'; 3 | 4 | export class Batch extends React.Component { 5 | 6 | render () { 7 | const {start, size, count, go} = this.props; 8 | const end = Math.min(start + size - 1, count - 1); 9 | return ( 10 |
11 | go(0)} 16 | /> 17 | go(Math.max(start - size, 0))} 22 | /> 23 | 24 | {start + 1} to {end + 1} of {count} 25 | 26 | go(start + size)} 31 | /> 32 | go(count - size)} 37 | /> 38 | 39 |
40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /client/ui/site.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {Tab, Tabs} from 'react-toolbox/lib/tabs'; 4 | import Dropdown from 'react-toolbox/lib/dropdown'; 5 | 6 | import {Base} from './app'; 7 | import {Dialog, DialogBase, Input, Select} from './dialog'; 8 | import {Frame} from './frame'; 9 | import {Reveal} from './revealbutton'; 10 | import {SiteAPI} from 'SiteAPI'; 11 | import {TooltipIconButton} from './util'; 12 | import {UserAvatar} from "./who"; 13 | 14 | import Intro from 'Intro'; 15 | 16 | export class Site extends Base { 17 | 18 | new_api() { 19 | return new SiteAPI(this); 20 | } 21 | 22 | render() { 23 | document.title = window.location.hostname || "Valuenator demo"; 24 | return ( 25 |
26 | 31 | 32 |
33 | ); 34 | } 35 | } 36 | 37 | export class AdminUI extends Site { 38 | 39 | constructor(props) { 40 | super(props); 41 | this.state.tab_index=0; 42 | } 43 | 44 | new_api() { 45 | return new SiteAPI(this); 46 | } 47 | 48 | render() { 49 | const {user, users} = this.state.model; 50 | if (user.email && ! user.admin) { 51 | window.location.hash = '#/'; 52 | return null; 53 | } 54 | document.title = window.location.hostname || "Admin"; 55 | 56 | return ( 57 |
58 | 64 | this.setState({tab_index: index})}> 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 |
74 | ); 75 | } 76 | } 77 | 78 | const user_types = [{label: "Normal", value: 0}, 79 | {label: "Adminstrator", value: 1}]; 80 | 81 | class Users extends React.Component { 82 | 83 | render() { 84 | const {users, api} = this.props; 85 | 86 | return ( 87 |
88 | 89 | 90 | 91 | 92 | {users.map((user) => ( 93 | 94 | 102 | 103 | 104 | 109 | 110 | ))} 111 | 112 |
NameEmailType
95 | 101 | {user.name}{user.email} api.change_user_type(user.id, v == 1)} 108 | />
113 |
114 | ); 115 | } 116 | } 117 | 118 | class Requests extends React.Component { 119 | 120 | constructor(props) { 121 | super(props); 122 | this.state = {}; 123 | this.get_requests(); 124 | } 125 | 126 | get_requests() { 127 | this.props.api.get_requests((requests) => { 128 | this.setState({requests: requests}); 129 | }); 130 | } 131 | 132 | approve(email) { 133 | this.props.api.approve(email, () => this.get_requests()); 134 | } 135 | 136 | render() { 137 | const {requests} = this.state; 138 | if (requests) { 139 | if (requests.length > 0) { 140 | return ( 141 |
142 | 143 | 144 | 145 | 146 | {requests.map((user) => ( 147 | 148 | 156 | 157 | 158 | 163 | 164 | ))} 165 | 166 |
NameEmail
149 | 155 | {user.name}{user.email} this.approve(user.email)} 161 | tooltip="Approve this user's request to join the team." 162 | />
167 |
168 | ); 169 | } 170 | else { 171 | return
There are no outstanding access requests.
; 172 | } 173 | } 174 | else { 175 | return
; 176 | } 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /client/ui/thebag.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Input from 'react-toolbox/lib/input'; 3 | 4 | import {has_text} from '../model/hastext'; 5 | 6 | import {Batch} from './search'; 7 | import {DropZone} from './dnd'; 8 | import {Reveal, Revealable, RevealButton} from './revealbutton'; 9 | import {TooltipIconButton} from './util'; 10 | 11 | const SEARCH_BATCH_SIZE = 9; 12 | 13 | export class TheBag extends Revealable { 14 | 15 | constructor(props) { 16 | super(props); 17 | this.state.search = ''; 18 | } 19 | 20 | size() { 21 | return '(' + this.props.board.archive_count + ')'; 22 | } 23 | 24 | dropped(id) { 25 | this.props.api.archive(id); 26 | } 27 | 28 | search(start=0) { 29 | console.log("searching"); 30 | this.props.api.get_archived(this.state.search, start, SEARCH_BATCH_SIZE); 31 | } 32 | 33 | features () { 34 | if (this.state.expanded) { 35 | const results = this.props.search_results; 36 | 37 | if (! results) { 38 | this.search(); 39 | return null; 40 | } 41 | 42 | const features = results.features.map((feature) => { 43 | return ( 44 | 48 | ); 49 | }); 50 | return ( 51 |
52 | this.search_input(v)} 57 | /> 58 | {features} 59 | this.search(pos)} 63 | /> 64 |
65 | ); 66 | } 67 | this.state.features = null; // Clear search 68 | return null; 69 | } 70 | 71 | clear_search_timeout() { 72 | if (this.search_timeout) { 73 | clearTimeout(this.search_timeout); 74 | this.search_timeout = undefined; 75 | } 76 | } 77 | 78 | search_input(v) { 79 | this.clear_search_timeout(); 80 | this.setState({search: v}); 81 | const timeout = setTimeout(() => { 82 | if (timeout === this.search_timeout) { 83 | this.search(); 84 | this.search_timeout = undefined; 85 | } 86 | }, 500); 87 | this.search_timeout = timeout; 88 | } 89 | 90 | render() { 91 | 92 | return ( 93 | this.dropped(data.id)} > 95 |
96 |

The Bag {this.size()}

97 | 100 |
101 | {this.features()} 102 |
103 | ); 104 | } 105 | } 106 | 107 | class ArchivedFeature extends Revealable { 108 | 109 | constructor(props) { 110 | super(props); 111 | this.update_stats(); 112 | } 113 | 114 | update_stats() { 115 | this.count = 0; 116 | this.size = 0; 117 | this.complete = 0; 118 | this.props.feature.tasks.forEach((task) => { 119 | this.count += 1; 120 | this.size += task.size; 121 | if (task.history[task.history.length - 1].complete) { 122 | this.complete += task.size; 123 | } 124 | }); 125 | } 126 | 127 | title() { 128 | return this.props.feature.title + 129 | ' [' + this.complete + '/' + this.size + ']'; 130 | } 131 | 132 | details() { 133 | if (has_text(this.props.feature.description)) { 134 | return ( 135 |
138 | ); 139 | } 140 | return null; 141 | } 142 | 143 | tasks() { 144 | return this.props.feature.tasks.map( 145 | (task) => ); 146 | } 147 | 148 | restore() { 149 | this.props.api.restore(this.props.feature.id); 150 | } 151 | 152 | render() { 153 | return ( 154 |
155 |
156 |

{this.title()}

157 | 160 |
161 | 162 | {this.details()} 163 | {this.tasks()} 164 | this.restore()} 167 | tooltip="Take this feature from the bag and work on it some more." 168 | /> 169 | 170 |
171 | ); 172 | } 173 | } 174 | 175 | 176 | class ArchivedTask extends Revealable { 177 | 178 | title() { 179 | const {task} = this.props; 180 | return task.title + (task.size > 1 ? ' [' + task.size + ']' : ''); 181 | } 182 | 183 | details() { 184 | const {task} = this.props; 185 | if (has_text(task.description)) { 186 | return ( 187 |
190 | ); 191 | } 192 | return null; 193 | } 194 | 195 | render() { 196 | return ( 197 |
198 |
199 |

{this.title()}

200 | 203 |
204 | 205 | {this.details()} 206 | 207 |
208 | ); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /client/ui/util.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {Button, IconButton} from 'react-toolbox/lib/button'; 4 | import {IconMenu} from 'react-toolbox/lib/menu'; 5 | import Tooltip from 'react-toolbox/lib/tooltip'; 6 | 7 | export const TooltipButton = Tooltip(Button); 8 | export const TooltipIconButton = Tooltip(IconButton); 9 | export const TooltipIconMenu = Tooltip(IconMenu); 10 | -------------------------------------------------------------------------------- /client/ui/who.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Avatar from 'react-toolbox/lib/avatar'; 3 | import Dropdown from 'react-toolbox/lib/dropdown'; 4 | import Tooltip from 'react-toolbox/lib/tooltip'; 5 | import md5 from 'md5'; 6 | 7 | import {Dialog, DialogBase, Input} from './dialog'; 8 | 9 | const TAvatar = Tooltip(Avatar); 10 | 11 | 12 | export const UserAvatar = (props) => { 13 | const {email, size, title} = props; 14 | const src = 'https://www.gravatar.com/avatar/' + 15 | md5(email) + '.jpg?s=' + (size || 32) + '&d=wavatar'; 16 | if (title) { 17 | return ; 18 | } 19 | else { 20 | return ; 21 | } 22 | }; 23 | 24 | export const User = (props) => ( 25 |
26 | 27 |
28 |
{props.user.name}
29 |
{props.user.nick} {props.user.email}
30 |
31 |
32 | ); 33 | 34 | const user_select_template = (user) => { 35 | if (user.value) { 36 | return ; 37 | } 38 | else { 39 | return
{user.title}
; 40 | } 41 | }; 42 | 43 | export const UserSelect = (props) => { 44 | const users = props.users.map( 45 | (u) => Object.assign({value: u.id}, u)); 46 | 47 | if (props.none) { 48 | users.unshift({value: '', title: props.none}); 49 | } 50 | 51 | return (); 59 | }; 60 | 61 | export class UserProfile extends DialogBase { 62 | 63 | render() { 64 | 65 | const finish = () => { 66 | this.props.finish( 67 | { 68 | id: this.state.id, 69 | name: this.state.name, 70 | email: this.state.email, 71 | nick: this.state.nick 72 | }); 73 | }; 74 | 75 | return ( 76 | 79 | 81 | 83 | 85 | 86 | ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /client/version.js: -------------------------------------------------------------------------------- 1 | export default '0.15.2'; 2 | -------------------------------------------------------------------------------- /default-states.json: -------------------------------------------------------------------------------- 1 | ["Backlog", "Ready", 2 | {"title": "Development", "explode": true, "working": true}, 3 | {"task": true, "title": "Ready", "id": "ready"}, 4 | {"task": true, "title": "Doing", "working": true}, 5 | {"task": true, "title": "Needs review"}, 6 | {"task": true, "title": "Review", "working": true}, 7 | {"task": true, "title": "Done", "complete": true}, 8 | {"title": "Acceptance", "working": true}, 9 | {"title": "Deploying", "working": true}, 10 | "Deployed" 11 | ] 12 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Two-Tiered Kanban 5 | 6 | 8 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = Featureflow 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Feature flow documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Jun 13 16:58:56 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | # import os 20 | # import sys 21 | # sys.path.insert(0, os.path.abspath('.')) 22 | 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | # 28 | # needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ['_templates'] 37 | 38 | # The suffix(es) of source filenames. 39 | # You can specify multiple suffix as a list of string: 40 | # 41 | # source_suffix = ['.rst', '.md'] 42 | source_suffix = '.rst' 43 | 44 | # The master toctree document. 45 | master_doc = 'contents' 46 | 47 | # General information about the project. 48 | project = u'Feature flow' 49 | copyright = u'2017, Feature-flow contributors' 50 | author = u'Feature-flow contributors' 51 | 52 | # The version info for the project you're documenting, acts as replacement for 53 | # |version| and |release|, also used in various other places throughout the 54 | # built documents. 55 | # 56 | # The short X.Y version. 57 | version = u'0.1' 58 | # The full version, including alpha/beta/rc tags. 59 | release = u'0.1' 60 | 61 | # The language for content autogenerated by Sphinx. Refer to documentation 62 | # for a list of supported languages. 63 | # 64 | # This is also used if you do content translation via gettext catalogs. 65 | # Usually you set "language" from the command line for these cases. 66 | language = None 67 | 68 | # List of patterns, relative to source directory, that match files and 69 | # directories to ignore when looking for source files. 70 | # This patterns also effect to html_static_path and html_extra_path 71 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 72 | 73 | # The name of the Pygments (syntax highlighting) style to use. 74 | pygments_style = 'sphinx' 75 | 76 | # If true, `todo` and `todoList` produce output, else they produce nothing. 77 | todo_include_todos = False 78 | 79 | 80 | # -- Options for HTML output ---------------------------------------------- 81 | 82 | # The theme to use for HTML and HTML Help pages. See the documentation for 83 | # a list of builtin themes. 84 | # 85 | html_theme = 'default' 86 | 87 | # Theme options are theme-specific and customize the look and feel of a theme 88 | # further. For a list of options available for each theme, see the 89 | # documentation. 90 | # 91 | # html_theme_options = {} 92 | 93 | # Add any paths that contain custom static files (such as style sheets) here, 94 | # relative to this directory. They are copied after the builtin static files, 95 | # so a file named "default.css" will overwrite the builtin "default.css". 96 | html_static_path = ['_static'] 97 | 98 | 99 | # -- Options for HTMLHelp output ------------------------------------------ 100 | 101 | # Output file base name for HTML help builder. 102 | htmlhelp_basename = 'Featureflowdoc' 103 | 104 | 105 | # -- Options for LaTeX output --------------------------------------------- 106 | 107 | latex_elements = { 108 | # The paper size ('letterpaper' or 'a4paper'). 109 | # 110 | # 'papersize': 'letterpaper', 111 | 112 | # The font size ('10pt', '11pt' or '12pt'). 113 | # 114 | # 'pointsize': '10pt', 115 | 116 | # Additional stuff for the LaTeX preamble. 117 | # 118 | # 'preamble': '', 119 | 120 | # Latex figure (float) alignment 121 | # 122 | # 'figure_align': 'htbp', 123 | } 124 | 125 | # Grouping the document tree into LaTeX files. List of tuples 126 | # (source start file, target name, title, 127 | # author, documentclass [howto, manual, or own class]). 128 | latex_documents = [ 129 | (master_doc, 'Featureflow.tex', u'Feature flow Documentation', 130 | u'Feature-flow contributors', 'manual'), 131 | ] 132 | 133 | 134 | # -- Options for manual page output --------------------------------------- 135 | 136 | # One entry per manual page. List of tuples 137 | # (source start file, name, description, authors, manual section). 138 | man_pages = [ 139 | (master_doc, 'featureflow', u'Feature flow Documentation', 140 | [author], 1) 141 | ] 142 | 143 | 144 | # -- Options for Texinfo output ------------------------------------------- 145 | 146 | # Grouping the document tree into Texinfo files. List of tuples 147 | # (source start file, target name, title, author, 148 | # dir menu entry, description, category) 149 | texinfo_documents = [ 150 | (master_doc, 'Featureflow', u'Feature flow Documentation', 151 | author, 'Featureflow', 'One line description of project.', 152 | 'Miscellaneous'), 153 | ] 154 | 155 | 156 | 157 | # -- Options for Epub output ---------------------------------------------- 158 | 159 | # Bibliographic Dublin Core info. 160 | epub_title = project 161 | epub_author = author 162 | epub_publisher = author 163 | epub_copyright = copyright 164 | 165 | # The unique identifier of the text. This can be a ISBN number 166 | # or the project homepage. 167 | # 168 | # epub_identifier = '' 169 | 170 | # A unique identification for the text. 171 | # 172 | # epub_uid = '' 173 | 174 | # A list of files that should not be packed into the epub file. 175 | epub_exclude_files = ['search.html'] 176 | -------------------------------------------------------------------------------- /doc/contents.rst: -------------------------------------------------------------------------------- 1 | .. Feature flow documentation master file, created by 2 | sphinx-quickstart on Tue Jun 13 16:58:56 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Feature flow's documentation! 7 | ======================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | index 14 | valuenator 15 | try 16 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Feature flow 3 | ============ 4 | 5 | Feature flow is an agile practice that seeks to provide value as 6 | quickly as possible by focusing teams on individual units of value, 7 | called "features". Ideally, a team works on one feature at a time 8 | [#tcboo]_. 9 | 10 | A feature remains a team focus until it's providing value. It's not 11 | enough, for example, for software to be checked in with tests 12 | passing. It must be in front of customers or otherwise providing 13 | stakeholder value. 14 | 15 | How it works 16 | ============ 17 | 18 | .. image:: sample-board.png 19 | :width: 40em 20 | 21 | Feature flow uses a two-level board. At the top level, is a feature 22 | board, showing features in progress or in the product backlog. The 23 | feature board provides a high-level view so stakeholders can see what's 24 | in progress and prioritize the backlog. 25 | 26 | When a feature enters development, a task board is used for the 27 | feature. This is the low-level view. 28 | 29 | At any point in time, there's feature board and 0 or more task boards, 30 | however, there should usually be only one or two task boards. 31 | 32 | Core concepts 33 | ============= 34 | 35 | Board 36 | A board provides a place for a team to track their work. 37 | 38 | Feature 39 | Features are units of value. 40 | 41 | We use the term *feature* rather than value to emphasize visibility. 42 | 43 | A feature will often encompass multiple stories, depending on the 44 | granularity of stories. A feature should be as small as possible 45 | and still provide value [#cd]_. 46 | 47 | Task 48 | Features are broken into tasks, so that work may be subdivided and 49 | that progress may be tracked. Features *can* be small enough to have 50 | a single task. That is a good thing, because it means that value 51 | can be recognized sooner, but typically features require multiple 52 | tasks. 53 | 54 | When a feature enters a development state, a task board for the 55 | feature is used, allowing the team to coordinate effort to 56 | drive the feature to completion. 57 | 58 | How it's implemented 59 | ==================== 60 | 61 | Feature flow can be implemented in a number of ways: 62 | 63 | - You can implement feature flow with a feature board and a collection of task 64 | boards, either using a software tool or sticky-notes on physical boards. 65 | 66 | - You can use a single board with big cards for features and sticky 67 | notes for tasks. When a feature enters a development state, you can 68 | move the stickies between development task states. 69 | 70 | - Possibly using a `tool that supports complex workflows 71 | `_. 72 | 73 | - The :doc:`Valuenator ` application. 74 | 75 | The Valuenator `open source application 76 | `_ is an attempt to 77 | automate the practice in a simple and opinionated way. There's a 78 | :ref:`demo version ` you can 79 | try without installing or signing up for anything to get a feel for 80 | the mechanics of the practice. 81 | 82 | However it's implemented, it's important that the implementation makes 83 | it easy to see everything relevant to a team at once. This is one 84 | reason why we think that a more specialized and opinionated tool has 85 | value. 86 | 87 | How it fits in with other agile practices 88 | ========================================= 89 | 90 | Like any other agile practice, feature flow is a part of a larger 91 | agile process that teams should tailor to their needs and experience 92 | through a process of "inspect and adapt". Just as software should be 93 | built incrementally, so should you evolve your agile processes 94 | incrementally. Feature flow is one part. 95 | 96 | Feature flow is an alternative to Scrum sprints. Rather than 97 | organizing work into fixed time increments, feature flow organizes 98 | around units of value. Features play a similar role to sprints, 99 | focusing a team on a shared goal and features are often similar in 100 | size to sprints. 101 | 102 | Organizing around value rather than time has a number of advantages: 103 | 104 | - It focuses the team on what's important to stakeholders. 105 | 106 | This may, for example, include activities outside of traditional 107 | development, such as deployment or training, because the team is 108 | focused on achieving value, not just finishing promised work. 109 | 110 | - It provides value as soon as possible, not just at sprint boundaries. 111 | 112 | - Much less time is spent in sprint planning, because there aren't sprints. 113 | 114 | - Team improvement can be considered at any time, rather than at 115 | sprint boundaries, because there's less emphasis on deadlines. 116 | 117 | Feature flow isn't new. Feature flow can be seen as an instance of 118 | `continuous flow 119 | `_, 120 | in that there's team focus on individual backlog items. 121 | 122 | Feature flow is based on two-tiered Kanban boards as described in the 123 | book `Kanban, by David Anderson 124 | `_ (and elsewhere). 125 | 126 | Feature flow can and should be used with other agile practices, as 127 | part of a larger process. 128 | 129 | 130 | .. [#tcboo] In practice, when a feature is nearing completion, there 131 | may not be enough work left to occupy the whole team, so the team 132 | may start another, however, the top priority of the team is getting 133 | the first task finished. 134 | 135 | .. [#cd] In a continuous-deployment environment, you might deploy 136 | subsets of features, with subsets not user-visible. This can help 137 | to avoid large software changes, to mitigate the risk of breakage. 138 | It can be argued that this provides value, but it's value that's 139 | not really visible to stake holders. Which isn't to say that 140 | feature flow and continuous deployment can't be used together, but 141 | they represent different kinds of flow. 142 | -------------------------------------------------------------------------------- /doc/sample-board.png: -------------------------------------------------------------------------------- 1 | ../screenshot.png -------------------------------------------------------------------------------- /doc/try.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | Trying Valuenator 3 | ================= 4 | 5 | .. _demo-label: 6 | 7 | Valuenator demo 8 | =============== 9 | 10 | The Valuenator demo is a version of Valuenator that stores its data 11 | locally in your browser. Data are stored persistently, so you can 12 | reload the demo page or restart your browser without losing data. 13 | 14 | The demo has all of the core features of the online version. 15 | 16 | The demo version has some limitations: 17 | 18 | - Because data are stored in your browser, you can't collaborate with 19 | other people. You also can't view data from multiple browsers. 20 | 21 | - Searching in the demo is a little different than in the online 22 | version. The demo searches for features and tasks using simple 23 | substring searches, while the on-line version uses a language-aware 24 | full-text search engine. 25 | 26 | - It requires a modern web browser. It is known to work with current 27 | versions of Chrome, Edge, Firefox, and Safari. Desktop browsers are 28 | required to drag tasks between states. 29 | 30 | To run the demo, open the following URL: 31 | 32 | http://valuenator.com/demo#/board/sample 33 | 34 | This will take you to a board with some sample features. 35 | 36 | See the :doc:`Valuenator documentation ` for information 37 | of using Valuenator. 38 | 39 | .. _beta-label: 40 | 41 | Valuenator beta 42 | =============== 43 | 44 | You can try the online version of Valuenator for free during the beta period. 45 | 46 | The goals of the beta are: 47 | 48 | - Get feedback 49 | 50 | - Find out what resources are needed to run Valuenator. 51 | 52 | To request beta access, fill out the `beta request form 53 | `_: 54 | 55 | https://goo.gl/forms/nxECJrBPCYB6WC6x2 56 | 57 | When we're ready, we'll get back to you with instructions for getting 58 | started. 59 | 60 | As with the demo version, the on-line beta requires a modern browser, 61 | such as Chrome, Edge, Firefox, or Safari. 62 | 63 | Docker image 64 | ============ 65 | 66 | There's a `Valuenator docker image 67 | `_ 68 | that you can use to easily deploy Valuenator yourself using Docker. 69 | 70 | Valuenator is open-source 71 | ========================= 72 | 73 | Valuenator is `open source 74 | `_. You can check it 75 | out from github and run it yourself, although the easiest way to run 76 | Valuenator yourself is with the docker image. 77 | 78 | 79 | -------------------------------------------------------------------------------- /docker/README.rst: -------------------------------------------------------------------------------- 1 | ================================ 2 | Deploying Valuenator with Docker 3 | ================================ 4 | 5 | .. contents:: 6 | 7 | 8 | Docker image 9 | ============ 10 | 11 | Get the image: 12 | 13 | - Use the jimfulton/valuenator image from the Docker Hub:: 14 | 15 | docker pull jimfulton/valuenator 16 | 17 | - build a docker image using the Dockerfile in the main repository directory:: 18 | 19 | docker build . 20 | 21 | Required services 22 | ================= 23 | 24 | You'll need a Postgres database to hold the data. 25 | 26 | You'll need to be able to send email. There's currently support or using an SMTP 27 | server or `AWS Simple Email Service (SES) `_. 28 | 29 | The valuenator process listens on port 8000, so you'll need to map 30 | some host port to that container port. 31 | 32 | Environment variables 33 | ===================== 34 | 35 | You'll also need to supply some environment variables when you run the image: 36 | 37 | DSN 38 | The `Postgres connection string 39 | `_ 40 | for your Postgres database. 41 | 42 | To send mail with SES: 43 | 44 | SES 45 | From address (e.g. ``support@valuenator.com``). 46 | 47 | Note that currently, only instance-role-based authentication is 48 | supported, so you'll need to run Valuenator from an EC2 49 | instance with an instance role that is allowed to send mail through 50 | your SES instance. 51 | 52 | To send mail with SMTP: 53 | 54 | SMTP 55 | From address (e.g. ``support@valuenator.com``). 56 | 57 | SMTP_HOST 58 | The host address of your SMTP server. 59 | 60 | SMTP_PORT 61 | The TCP port your SMTP server listens on. 62 | 63 | SMTP_USER and SMTP_PASSWORD (optional) 64 | If your SMTP server requires authentication, then these are the user 65 | name and password. 66 | 67 | SMTP_TLS (optional) 68 | If this is supplied as a non-empty string, then TLS (Transport Layer 69 | Security) will be used to send email to the server over an encrypted 70 | connection. 71 | 72 | You can be notified of errors using `Sentry `_: 73 | 74 | RAVEN 75 | A sentry DSN to be used for the server. 76 | 77 | JSRAVEN 78 | A sentry DSN to be used for the client. 79 | 80 | Bootstrapping 81 | ============= 82 | 83 | You'll need to set up a site and bootstrap administrative user. You 84 | can define multiple sites. When the server gets a request, it looks 85 | up a site based on the request domain. 86 | 87 | Users are added to a site by asking them to request access, by going 88 | to the site and filling out a request form. An administrative user 89 | reviews and approves requests. An initial administrative user needs to 90 | be defined to to get the process started. 91 | 92 | To bootstrap a site and initial administrative user, run the image with 93 | these extra environment variables: 94 | 95 | DOMAIN 96 | The domain the site will be accessed at (e.g. ``localhost`` or 97 | ``kanban.example.com``) 98 | 99 | TITLE 100 | The site title. 101 | 102 | This is included in set-password emails and should be descriptive 103 | enough that recipients think the emails are legitimate. 104 | 105 | EMAIL 106 | The initial user's email address. 107 | 108 | An email will be sent to this address with a link to set their 109 | password. 110 | 111 | NAME 112 | The name of the initial user. 113 | 114 | BASE_URL (optional) 115 | The base URL to use in the set-password link. 116 | 117 | This defaults to ``HTTP://DOMAIN``. If you're server listens on a 118 | non-standard port, or you use HTTPS (as you should in production), 119 | you might want to supply this. Otherwise, you'll need to edit the 120 | set-password, which is probably easier. 121 | 122 | You'll also need to specify the DSN and email-related variables 123 | specified above. 124 | 125 | Examples 126 | ======== 127 | 128 | Bootstrap a (test) site that runs on localhost 129 | ---------------------------------------------- 130 | 131 | :: 132 | 133 | docker run -it \ 134 | -e 'DSN=postgresql://myserver/kanban' \ 135 | -e 'SMTP=support@example.com' \ 136 | -e 'SMTP_HOST=email-smtp.us-east-1.amazonaws.com' \ 137 | -e 'SMTP_PORT=587' \ 138 | -e 'SMTP_USER=USER' \ 139 | -e 'SMTP_PW=PW' \ 140 | -e 'SMTP_TLS=T' \ 141 | -e DOMAIN=localhost \ 142 | -e EMAIL=me@eample.com \ 143 | -e NAME=Me \ 144 | -e 'TITLE=Test Site' \ 145 | jimfulton/valuenator 146 | 147 | Run the server, listening on port 8080 148 | -------------------------------------- 149 | 150 | :: 151 | 152 | docker run -d \ 153 | -e 'DSN=postgresql://myserver/kanban' \ 154 | -e 'SMTP=support@example.com' \ 155 | -e 'SMTP_HOST=email-smtp.us-east-1.amazonaws.com' \ 156 | -e 'SMTP_PORT=587' \ 157 | -e 'SMTP_USER=USER' \ 158 | -e 'SMTP_PW=PW' \ 159 | -e 'SMTP_TLS=T' \ 160 | -p 8080:8000 \ 161 | jimfulton/valuenator 162 | -------------------------------------------------------------------------------- /docker/build.cfg: -------------------------------------------------------------------------------- 1 | [buildout] 2 | directory = /app 3 | extends = /app/buildout.cfg 4 | parts = wsgirunner js-build recipes 5 | extras = [raven, ses] 6 | 7 | [recipes] 8 | recipe = zc.recipe.egg 9 | eggs = zc.recipe.deployment 10 | gocept.recipe.env 11 | -------------------------------------------------------------------------------- /docker/start.cfg: -------------------------------------------------------------------------------- 1 | [buildout] 2 | directory = /app 3 | extends = /app/buildout.cfg /app/release.cfg 4 | develop = 5 | database = ${env:DSN} 6 | offline = true 7 | extra-configure = twotieredkanban.emailpw:config 8 | extra-options = sendmail = twotieredkanban.emailpw:env_sendmail_config() 9 | 10 | [env] 11 | recipe = gocept.recipe.env 12 | 13 | [buildout:os.environ.get('RAVEN')] 14 | raven-logging = 15 | %import j1m.ravenzconfig 16 | 17 | dsn ${env:RAVEN} 18 | release ${buildout:release} 19 | level ERROR 20 | 21 | 22 | [buildout:os.environ.get('JSRAVEN')] 23 | jsraven = ${env:JSRAVEN} 24 | 25 | [paste.ini] 26 | logpath = STDOUT 27 | -------------------------------------------------------------------------------- /docker/start.sh: -------------------------------------------------------------------------------- 1 | cd /app 2 | /env/bin/buildout -c docker/start.cfg install paste.ini dbclient 3 | if [ -n "$DOMAIN" ]; then 4 | /app/bin/emailpw-bootstrap -t "$TITLE" -b "$BASE_URL" --env-config \ 5 | /app/db.cfg "$DOMAIN" "$EMAIL" "$NAME" 6 | else 7 | /app/bin/run-wsgi /app/parts/paste.ini 8 | fi 9 | -------------------------------------------------------------------------------- /express/README.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | Express server 3 | ============== 4 | 5 | This directory provides support for running the demp app using a local 6 | server, rather than ``file://`` URLs, which doesn't work well for 7 | Firefox or Safary. 8 | 9 | Build 10 | Change to this directory, then:: 11 | 12 | npm install 13 | 14 | Run 15 | Change to this directory then:: 16 | 17 | npm start 18 | 19 | Then open http://localhost:3000 in your browser. 20 | -------------------------------------------------------------------------------- /express/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "server.js", 3 | "dependencies": { 4 | "express": "^4.15.3" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /express/server.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var app = express(); 3 | var port = 3000; 4 | 5 | app.use(express.static('../demo')); 6 | 7 | app.listen(port, function () { 8 | console.log("Server is listening on part " + port); 9 | }); 10 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | var webpackConfig = require('./webpack.config.js'); 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | browsers: ['Chrome'], 6 | singleRun: true, 7 | frameworks: ['mocha'], 8 | files: ['client/tests/test*.jsx'], 9 | preprocessors: { 10 | 'client/tests/test*.jsx': ['webpack', 'sourcemap'] 11 | }, 12 | reporters: ['mocha'], 13 | client: { 14 | mocha: {timeout: '5000'} 15 | }, 16 | webpack: webpackConfig, 17 | webpackServer: { 18 | noInfo: true 19 | } 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "repository": { 4 | "type": "git", 5 | "url": "https://github.com/feature-flow/twotieredkanban.git" 6 | }, 7 | "version": "1.0.0", 8 | "description": "simple react app", 9 | "main": "index.js", 10 | "scripts": { 11 | "test": "mocha-webpack --webpack-config webpack.config-test.js \"client/tests/test*.js\" --require source-map-support/register" 12 | }, 13 | "author": "Jim Fulton", 14 | "license": "MIT", 15 | "dependencies": { 16 | "axios": "^0.19.0", 17 | "classnames": "^2.2.5", 18 | "draft-js": "^0.10.1", 19 | "material-design-icons": "^3.0.1", 20 | "md5": "^2.2.1", 21 | "raven-js": "^3.15.0", 22 | "react": "~15.5.0", 23 | "react-addons-css-transition-group": "~15.5.0", 24 | "react-dom": "~15.5.0", 25 | "react-router": "^2.0.0", 26 | "react-rte": "^0.11.0", 27 | "react-toolbox": "^2.0.0-beta.12", 28 | "uuid": "^3.0.1" 29 | }, 30 | "devDependencies": { 31 | "babel": "^6.23.0", 32 | "babel-core": "^6.24.1", 33 | "babel-loader": "^7.0.0", 34 | "babel-preset-es2015": "^6.24.1", 35 | "babel-preset-react": "^6.24.1", 36 | "babel-register": "^6.24.1", 37 | "camelcase": "^4.1.0", 38 | "chai": "^3.5.0", 39 | "css-loader": "^0.28.1", 40 | "expect": "^1.20.2", 41 | "fake-indexeddb": "^2.0.3", 42 | "html-loader": "^0.4.5", 43 | "jquery": "^3.2.1", 44 | "json-loader": "^0.5.4", 45 | "karma": "^1.7.0", 46 | "karma-chrome-launcher": "^2.1.1", 47 | "karma-mocha": "^1.3.0", 48 | "karma-mocha-reporter": "^2.2.3", 49 | "karma-sourcemap-loader": "^0.3.7", 50 | "karma-webpack": "^2.0.3", 51 | "mocha": "^3.3.0", 52 | "mocha-webpack": "^0.7.0", 53 | "mockdate": "^2.0.1", 54 | "node-sass": "^4.5.3", 55 | "postcss-cssnext": "^2.10.0", 56 | "postcss-each": "^0.9.3", 57 | "postcss-import": "^9.1.0", 58 | "postcss-loader": "^1.3.3", 59 | "postcss-mixins": "^5.4.1", 60 | "react-addons-test-utils": "~15.5.0", 61 | "sass-loader": "^6.0.3", 62 | "script-loader": "^0.7.0", 63 | "source-map-support": "^0.4.15", 64 | "style-loader": "^0.17.0", 65 | "webpack": "^2.5.1", 66 | "webpack-node-externals": "^1.6.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-import': { 4 | root: __dirname, 5 | }, 6 | 'postcss-mixins': {}, 7 | 'postcss-each': {}, 8 | 'postcss-cssnext': {} 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /raven.cfg: -------------------------------------------------------------------------------- 1 | [buildout] 2 | extends = buildout.cfg release.cfg 3 | raven-logging = 4 | %import j1m.ravenzconfig 5 | 6 | dsn ${buildout:raven} 7 | release ${buildout:release} 8 | level ERROR 9 | 10 | 11 | [wsgirunner] 12 | eggs += raven 13 | j1m.ravenzconfig 14 | 15 | -------------------------------------------------------------------------------- /release.cfg: -------------------------------------------------------------------------------- 1 | [buildout] 2 | release = 0.15.2 3 | -------------------------------------------------------------------------------- /release.py: -------------------------------------------------------------------------------- 1 | # Quick and dirty script to make releases. Generally modeled on 2 | # zest.release, but far less ambitious. 3 | import datetime 4 | import os 5 | 6 | with open('CHANGES.rst') as f: 7 | changes = f.read() 8 | 9 | before, after = changes.split('(unreleased)') 10 | before, version = before.rsplit('\n', 1) 11 | version = version.strip() 12 | 13 | M, m, p = map(int, version.split('.')) 14 | 15 | changes = before + (""" 16 | %d.%d.%d (unreleased) 17 | ===================== 18 | 19 | Nothing changed yet 20 | 21 | %s (%s)""" % (M, m, p+1, version, datetime.date.today().isoformat()) 22 | ) + after 23 | 24 | with open('release.cfg', 'w') as f: 25 | f.write("[buildout]\nrelease = %s\n" % version) 26 | with open('client/version.js', 'w') as f: 27 | f.write("export default %r;\n" % version) 28 | 29 | with open('CHANGES.rst', 'w') as f: 30 | f.write(changes) 31 | 32 | if not os.system("git commit -am 'Releasing %s'" % version): 33 | if not os.system("git tag '%s'" % version): 34 | if not os.system("git push"): 35 | os.system("git push --tags") 36 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | BTrees == 4.4.1 2 | Paste == 2.0.3 3 | PasteDeploy == 1.5.2 4 | PyJWT == 1.5.0 5 | RelStorage == 2.1a2 6 | WebOb == 1.7.2 7 | WebTest == 2.0.27 8 | ZConfig == 3.1.0 9 | ZEO == 5.1.0 10 | ZODB == 5.2.4 11 | beautifulsoup4 == 4.6.0 12 | bleach == 2.0.0 13 | bobo == 2.4.0 14 | cffi == 1.10.0 15 | collective.recipe.cmd == 0.11 16 | gevent == 1.2.2 17 | greenlet == 0.4.12 18 | html5lib == 0.999999999 19 | jinja2 == 2.9.6 20 | manuel == 1.8.0 21 | mock == 2.0.0 22 | newt.db == 0.8.0 23 | newt.qbe == 0.1.1 24 | passlib == 1.7.1 25 | pbr == 3.0.1 26 | perfmetrics == 2.0 27 | persistent == 4.2.4.2 28 | psycogreen == 1.0 29 | psycopg2 == 2.7.1 30 | pycparser == 2.17 31 | repoze.retry == 1.4 32 | six == 1.10.0 33 | transaction == 2.1.2 34 | waitress == 1.4.3 35 | webencodings == 0.5.1 36 | zc.generationalset == 0.4.0 37 | zc.lockfile == 1.2.1 38 | zc.recipe.deployment == 1.3.0 39 | zc.recipe.egg == 2.0.3 40 | zc.recipe.testrunner == 2.0.0 41 | zc.wsgirunner == 0.1.0 42 | zc.zdaemonrecipe == 1.0.0 43 | zc.zodbwsgi == 1.2.0 44 | zdaemon == 4.2.0 45 | zodbpickle == 0.6.0 46 | zope.exceptions == 4.1.0 47 | zope.interface == 4.4.2 48 | zope.testing == 4.6.2 49 | zope.testrunner == 4.7.0 50 | MarkupSafe == 1.0 51 | dnspython == 1.15.0 52 | email-validator == 1.0.2 53 | idna == 2.5 54 | testvars == 1.0.0 55 | boto == 2.48.0 56 | gocept.recipe.env == 1.0 57 | j1m.ravenzconfig == 0.1.1 58 | raven == 6.1.0 59 | zc.buildout == 2.9.4 60 | -------------------------------------------------------------------------------- /rpm.cfg: -------------------------------------------------------------------------------- 1 | [buildout] 2 | extends = buildout.cfg 3 | parts += zookeeper-deploy 4 | allow-picked-versions = false 5 | 6 | extensions = 7 | index = 8 | find-links = 9 | 10 | [zookeeper-deploy] 11 | recipe = zc.recipe.egg 12 | eggs = zc.zookeeper_deploy_buildout 13 | arguments = '', 'zc.asanakanban' 14 | -------------------------------------------------------------------------------- /rpm.spec: -------------------------------------------------------------------------------- 1 | Name: akb 2 | Version: 0 3 | Release: 1 4 | 5 | Summary: Asana-based kanban board 6 | Group: Applications/Database 7 | Requires: cleanpython26 8 | Requires: libevent 9 | BuildRequires: cleanpython26 10 | BuildRequires: libevent-devel 11 | %define python /opt/cleanpython26/bin/python 12 | 13 | ########################################################################## 14 | # Lines below this point normally shouldn't change 15 | 16 | %define source %{name}-%{version} 17 | 18 | Vendor: Zope Corporation 19 | Packager: Zope Corporation 20 | License: ZVSL 21 | AutoReqProv: no 22 | Source: %{source}.tgz 23 | Prefix: /opt 24 | BuildRoot: /tmp/%{name} 25 | 26 | %description 27 | %{summary} 28 | 29 | %prep 30 | %setup -n %{source} 31 | 32 | %build 33 | rm -rf %{buildroot} 34 | mkdir %{buildroot} %{buildroot}/opt 35 | cp -r $RPM_BUILD_DIR/%{source} %{buildroot}/opt/%{name} 36 | %{python} %{buildroot}/opt/%{name}/install.py bootstrap 37 | %{python} %{buildroot}/opt/%{name}/install.py buildout:extensions= 38 | %{python} -m compileall -q -f -d /opt/%{name}/eggs \ 39 | %{buildroot}/opt/%{name}/eggs \ 40 | > /dev/null 2>&1 || true 41 | rm -rf %{buildroot}/opt/%{name}/release-distributions 42 | 43 | # Gaaaa! buildout doesn't handle relative paths in egg links. :( 44 | sed -i s-/tmp/%{name}-- \ 45 | %{buildroot}/opt/%{name}/develop-eggs/zc.asanakanban.egg-link 46 | %clean 47 | rm -rf %{buildroot} 48 | rm -rf $RPM_BUILD_DIR/%{source} 49 | 50 | %files 51 | %defattr(-, root, root) 52 | /opt/%{name} 53 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feature-flow/twotieredkanban/d1c76ae1d578f39670ee744a4ef88e39b7957df5/screenshot.png -------------------------------------------------------------------------------- /server/twotieredkanban/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feature-flow/twotieredkanban/d1c76ae1d578f39670ee744a4ef88e39b7957df5/server/twotieredkanban/__init__.py -------------------------------------------------------------------------------- /server/twotieredkanban/apibase.py: -------------------------------------------------------------------------------- 1 | import bobo 2 | import logging 3 | import os 4 | import webob 5 | 6 | from .apisite import Site 7 | from .apiboard import Board 8 | from .apiutil import get 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | @bobo.subroute("", scan=True) 13 | class Base: 14 | 15 | user = None 16 | 17 | def __init__(self, request): 18 | self.request = request 19 | self.connection = connection = request.environ['zodb.connection'] 20 | self.root = root = connection.root 21 | self.site = root.sites.get(request.domain, NoSite) 22 | self.auth = self.site.auth 23 | 24 | 25 | def check(self, func=None): 26 | user = self.auth.user(self.request) 27 | if user is None: 28 | return self.auth.login(self.request) 29 | self.user = user 30 | if func.__name__.startswith('admin_'): 31 | if not self.user.get('admin'): 32 | self.error(403, dict(error="You must be an adminstrator")) 33 | 34 | def error(self, status, body): 35 | self.connection.transaction_manager.abort() 36 | if isinstance(body, str): 37 | body = dict(error=body) 38 | raise bobo.BoboException(status, body, "application/json") 39 | 40 | @get("/") 41 | def index_html(self): 42 | with open(os.path.join(os.path.dirname(__file__), "kb.html")) as f: 43 | response = webob.Response(f.read()) 44 | 45 | return response 46 | 47 | @get("/favicon.ico", content_type="image/x-icon") 48 | def favicon(self): 49 | with open(os.path.join(os.path.dirname(__file__), "favicon.ico"), 50 | 'rb') as f: 51 | response = webob.Response(f.read()) 52 | 53 | return response 54 | 55 | @bobo.get("/ruok") 56 | def ruok(self): 57 | return 'imok' 58 | 59 | @bobo.subroute('/site') 60 | def admin_api(self, request): 61 | return Site(self, self.site) 62 | 63 | @bobo.subroute('/auth') 64 | def auth_api(self, request): 65 | return self.auth.subroute(self) 66 | 67 | @bobo.subroute('/board/:board') 68 | def board(self, request, board): 69 | b = self.site.boards.get(board) 70 | if b: 71 | router = Board(self, b) 72 | router.check = self.check 73 | return router 74 | 75 | raise bobo.NotFound 76 | 77 | @bobo.get('/not-yet') 78 | def not_yet(self): 79 | return "This site isn't available yet." 80 | 81 | # bobo errors exception 82 | def exception(request, method, exc_info): 83 | logger.error("request failed: %s", request.url, exc_info=exc_info) 84 | raise exc_info[1] 85 | 86 | no_site_url = '/not-yet' # can be replaced by config 87 | class NoSite: 88 | 89 | class auth: 90 | 91 | @classmethod 92 | def user(*_): 93 | pass 94 | 95 | @classmethod 96 | def login(*_): 97 | return bobo.redirect(no_site_url) 98 | 99 | def config(options): 100 | if 'no_site_url' in options: 101 | global no_site_url 102 | no_site_url = options['no_site_url'] 103 | -------------------------------------------------------------------------------- /server/twotieredkanban/apiboard.py: -------------------------------------------------------------------------------- 1 | import bobo 2 | from contextlib import closing 3 | import datetime 4 | import json 5 | import logging 6 | import newt.db.search 7 | import time 8 | import webob 9 | from ZODB.utils import u64 10 | 11 | from . import sql 12 | from .apiutil import Sync, delete, get, post, put 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | @bobo.scan_class 17 | class Board(Sync): 18 | 19 | @put("/") 20 | def admin_rename_board(self, name): 21 | self.base.site.rename(self.context.name, name) 22 | return self.response() 23 | 24 | @post("/projects") 25 | def add_project(self, title, order, description=''): 26 | self.context.new_project(title, description=description, order=order) 27 | return self.response() 28 | 29 | @put("/move/:task_id") 30 | def move(self, task_id, state_id=None, order=None, parent_id=None): 31 | self.context.move(task_id, parent_id, state_id, order, 32 | user_id=self.base.user['id']) 33 | return self.response() 34 | 35 | @post("/project/:id") 36 | def add_task(self, id, title, order, 37 | description='', size=1, blocked='', assigned=None): 38 | task = self.context.new_task( 39 | id, title, description=description, 40 | size=size, blocked=blocked, assigned=assigned, 41 | order=order, 42 | ) 43 | return self.response() 44 | 45 | @put("/tasks/:task_id") 46 | def update_task(self, bobo_request, task_id): 47 | self.context.update_task(task_id, **bobo_request.json) 48 | return self.response() 49 | 50 | @delete("/tasks/:task_id") 51 | def delete_task(self, bobo_request, task_id): 52 | self.context.remove(task_id) 53 | return self.response() 54 | 55 | @post("/archive/:feature_id") 56 | def archive_feature(self, feature_id): 57 | self.context.archive_feature(feature_id) 58 | return self.response() 59 | 60 | @delete("/archive/:feature_id") 61 | def restore_feature(self, request, feature_id): 62 | self.context.restore_feature(feature_id) 63 | return self.response() 64 | 65 | @get("/archive") 66 | def search_archived(self, text=None, start=0, size=None): 67 | board = self.context 68 | 69 | if size: 70 | count, features = newt.db.search.where_batch( 71 | board._p_jar, 72 | archive_where(board, text), (), int(start), int(size)) 73 | return self.response(count=count, features=features) 74 | else: 75 | return self.response( 76 | features=newt.db.search.where( 77 | board._p_jar, 78 | archive_where(board, text)) 79 | ) 80 | 81 | @get("/export") 82 | def admin_export(self): 83 | board = self.context 84 | return self._response(dict( 85 | board=board, 86 | users=board.site.users, 87 | states=list(board.states), 88 | tasks=list(board.tasks), 89 | archive=list(board.archive.values()) 90 | )) 91 | 92 | def archive_where(board, text=None): 93 | conn = board._p_jar 94 | q = dict(archived='true', board=u64(board._p_oid)) 95 | if text: 96 | q['text'] = text 97 | order_by = ('text', True) 98 | else: 99 | order_by = ('modified', True) 100 | return sql.qbe.sql(conn, q, order_by=(order_by,)) 101 | -------------------------------------------------------------------------------- /server/twotieredkanban/apisite.py: -------------------------------------------------------------------------------- 1 | import bobo 2 | 3 | from .apiutil import Sync, post, put 4 | 5 | @bobo.scan_class 6 | class Site(Sync): 7 | pass 8 | -------------------------------------------------------------------------------- /server/twotieredkanban/apiutil.py: -------------------------------------------------------------------------------- 1 | import bobo 2 | import datetime 3 | import json 4 | import webob 5 | 6 | def check(self, request, func): 7 | return self.check(func) 8 | 9 | def get(*args, **kw): 10 | return bobo.get(*args, check=check, **kw) 11 | 12 | def post(*args, **kw): 13 | return bobo.post(*args, check=check, **kw) 14 | 15 | def put(*args, **kw): 16 | return bobo.put(*args, check=check, **kw) 17 | 18 | def delete(*args, **kw): 19 | return bobo.delete(*args, check=check, **kw) 20 | 21 | class Encoder(json.JSONEncoder): 22 | 23 | def default(self, obj): 24 | if isinstance(obj, datetime.datetime): 25 | return obj.isoformat()[:19] 26 | return obj.json_reduce() 27 | 28 | class Sync: 29 | 30 | def __init__(self, base, context): 31 | self.base = base 32 | self.context = context 33 | 34 | def check(self, func=None): 35 | return self.base.check(func) 36 | 37 | def _response(self, data=None): 38 | response = webob.Response(content_type="application/json") 39 | response.text = json.dumps(data, cls=Encoder) if data else '{}' 40 | response.cache_control = 'no-cache' 41 | response.pragma = 'no-cache' 42 | return response 43 | 44 | def response(self, send_user=None, **data): 45 | generation = int(self.base.request.headers.get('x-generation', 0)) 46 | updates = self.context.updates(generation) 47 | if generation == 0: 48 | # first load, set uswer 49 | updates['user'] = self.base.user 50 | if raven: 51 | updates['raven'] = dict( 52 | url=raven, 53 | options=dict(tags=dict(server_release=release)) 54 | ) 55 | 56 | if send_user: 57 | updates['user'] = send_user 58 | 59 | if updates: 60 | data['updates'] = updates 61 | return self._response(data) 62 | 63 | @get("/longpoll") 64 | @get("/poll") 65 | def poll(self): 66 | return self.response() 67 | 68 | @post('/boards') 69 | def admin_post_board(self, name, title, description): 70 | site = self.base.site 71 | if name in site.boards: 72 | self.base.error("A board with name %r already exists." % name) 73 | site.add_board(name, title, description) 74 | return self.response() 75 | 76 | raven = release = None 77 | def config(options): 78 | global raven, release 79 | raven = options.get('raven') 80 | release = options.get('release') 81 | -------------------------------------------------------------------------------- /server/twotieredkanban/auth.text: -------------------------------------------------------------------------------- 1 | Some notes on authentication. 2 | 3 | Authentication will be performed in 2 steps. 4 | 5 | Initial authentication will use oauth in production. For dev, we'll 6 | use a puntastic form of authentication where a use need only supply an 7 | email query string on the / request. Note that a minimal version of 8 | the punt is *all* we're doing now. 9 | 10 | Initial authentication will cause a JWT token to be saved in a cookie 11 | and will suffice for authentication when present. A logout route 12 | clears this. 13 | -------------------------------------------------------------------------------- /server/twotieredkanban/default-states.json: -------------------------------------------------------------------------------- 1 | ../../default-states.json -------------------------------------------------------------------------------- /server/twotieredkanban/emailpw-templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ title }} 4 | 5 | 6 | 7 | 8 |

{% block prompt %}{% endblock %}

9 |
10 | {% block content %}{% endblock %} 11 | 12 | 13 | -------------------------------------------------------------------------------- /server/twotieredkanban/emailpw-templates/emailpw.css: -------------------------------------------------------------------------------- 1 | .w3-card { 2 | padding: 2em; 3 | border-radius: 1em; 4 | } 5 | label::after { 6 | content: ':'; 7 | } 8 | -------------------------------------------------------------------------------- /server/twotieredkanban/emailpw-templates/forgot.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %}{% block title %}{{ title }}{% endblock %} 2 | {% block prompt %}Enter the email address you use to sign in.{% endblock %} 3 | {% block content %} 4 |

We'll send you a link you can use to enter a new password.

5 |
6 | {% if message %}

{{message}}

{% endif %} 7 |


8 |

9 |

10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /server/twotieredkanban/emailpw-templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %}{% block title %}{{ title }}{% endblock %} 2 | {% block prompt %}Log into {{title}}.{% endblock %} 3 | {% block content %} 4 |
5 | {% if message %}

{{message}}

{% endif %} 6 |


7 |

8 |


9 |

10 |

11 |

12 | Forgot password 13 | | 14 | Request access 15 |

16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /server/twotieredkanban/emailpw-templates/message.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %}{% block title %}{{ title }}{% endblock %} 2 | {% block prompt %}{{ heading }}{% endblock %} 3 | {% block content %} 4 |

{{ message }}

5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /server/twotieredkanban/emailpw-templates/pw.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %}{% block title %}{{ title }}{% endblock %} 2 | {% block prompt %}{{ action }} your password for {{title}}.{% endblock %} 3 | {% block content %} 4 |
5 | {{ message }} 6 |


9 |

10 |


11 |

12 |

13 |

14 |
15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /server/twotieredkanban/emailpw-templates/request.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %}{% block title %}{{ title }}{% endblock %} 2 | {% block prompt %}Request access to {{title}}.{% endblock %} 3 | {% block content %} 4 |
5 | {% if message %}

{{message}}

{% endif %} 6 |


7 |

8 |


9 |

10 |

11 |
12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /server/twotieredkanban/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feature-flow/twotieredkanban/d1c76ae1d578f39670ee744a4ef88e39b7957df5/server/twotieredkanban/favicon.ico -------------------------------------------------------------------------------- /server/twotieredkanban/initializedb.py: -------------------------------------------------------------------------------- 1 | from .sql import evolve 2 | 3 | def config(options): 4 | global dsn 5 | dsn = options['dsn'] 6 | 7 | def initialize(db): 8 | evolve(dsn) 9 | 10 | -------------------------------------------------------------------------------- /server/twotieredkanban/interfaces.py: -------------------------------------------------------------------------------- 1 | """Plugin interfaces 2 | """ 3 | 4 | try: 5 | from zope.interface import Interface 6 | except ImportError: 7 | Interface = object 8 | 9 | class IAuthentication(Interface): 10 | 11 | def user(request): 12 | """Return authenticated user data for the request as a mapping. 13 | 14 | The user should be a dict with id, name, email, ``admin`` 15 | flag, and nick. 16 | """ 17 | 18 | def login(request): 19 | """Present a login interface. 20 | """ 21 | 22 | def subroute(base): 23 | """Return a bobo subroute using a base API. 24 | """ 25 | -------------------------------------------------------------------------------- /server/twotieredkanban/jwtauth.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | import jwt.exceptions 3 | import time 4 | 5 | TOKEN='auth_token' 6 | 7 | def token(secret, **data): 8 | return jwt.encode(data, secret, algorithm='HS256').decode('utf-8') 9 | 10 | def decode(token, secret, timeout=None): 11 | try: 12 | data = jwt.decode(token, secret, algorithms=['HS256']) 13 | 14 | if timeout and data['time'] + timeout < time.time(): 15 | return None 16 | 17 | return data 18 | except jwt.exceptions.DecodeError: 19 | return None 20 | 21 | def save(jar, secret, secure=False, **data): 22 | jar.set_cookie(TOKEN, token(secret, **data), secure=secure, httponly=True) 23 | 24 | def load(jar, secret): 25 | token = jar.cookies.get(TOKEN) 26 | data = token and decode(token, secret) 27 | return data 28 | -------------------------------------------------------------------------------- /server/twotieredkanban/kb.html: -------------------------------------------------------------------------------- 1 | ../../client/kb.html -------------------------------------------------------------------------------- /server/twotieredkanban/server.py: -------------------------------------------------------------------------------- 1 | import collections 2 | from contextlib import closing 3 | import gevent.event 4 | import gevent.monkey 5 | import gevent.pywsgi 6 | import logging 7 | import psycogreen.gevent 8 | import newt.db.follow 9 | from newt.db import pg_connection 10 | import sys 11 | from ZODB.utils import p64 12 | 13 | gevent.monkey.patch_all() 14 | psycogreen.gevent.patch_psycopg() 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | def runner(app, conf, dsn, host='', port=8080): 19 | @gevent.spawn 20 | def handle_updates(): 21 | try: 22 | for zoid in updates(dsn): 23 | for e in pollers.pop(zoid, ()): 24 | e.set() 25 | except Exception: 26 | logger.critical("Change poll loop failed", exc_info=True) 27 | sys.exit(1) 28 | 29 | db = app.database 30 | def polling_app(environ, start_response): 31 | # If we're sitting behind an elb providing HTTPS: 32 | forwarded_proto = environ.get('HTTP_X_FORWARDED_PROTO') 33 | if forwarded_proto: 34 | environ['wsgi.url_scheme'] = forwarded_proto 35 | 36 | path = environ['PATH_INFO'] 37 | if path.endswith('/longpoll'): 38 | client_gen = int(environ.get('HTTP_X_GENERATION', 0)) 39 | if client_gen: 40 | zoid = int(environ['HTTP_X_GENERATION_ZOID']) 41 | event = gevent.event.Event() 42 | pollers[zoid].append(event) 43 | with db.transaction() as conn: 44 | gen = conn.get(p64(zoid)).generation 45 | 46 | if gen <= client_gen: 47 | # Wait for updates 48 | event.wait(300) 49 | else: 50 | try: 51 | pollers[zoid].remove(event) 52 | except IndexError: 53 | pass 54 | 55 | return app(environ, start_response) 56 | 57 | gevent.pywsgi.WSGIServer( 58 | (host, int(port)), polling_app, 59 | log=logger, error_log=logger 60 | ).serve_forever() 61 | 62 | pollers = collections.defaultdict(list) # {zoid -> [events]} 63 | 64 | def updates(dsn): 65 | with closing(pg_connection(dsn)) as conn: 66 | with closing(conn.cursor()) as cursor: 67 | cursor.execute("select max(tid) from object_state") 68 | [[tid]] = cursor 69 | cursor.execute("""prepare get_updates(bigint) as 70 | select max(tid) over(), zoid 71 | from object_state join newt using(zoid) 72 | where class_name = 'zc.generationalset.GenerationalSet' 73 | and tid > $1 74 | """) 75 | for _ in newt.db.follow.listen(dsn, True): 76 | cursor.execute("execute get_updates(%s)", (tid,)) 77 | for tid, zoid in cursor: 78 | yield zoid 79 | -------------------------------------------------------------------------------- /server/twotieredkanban/ses.py: -------------------------------------------------------------------------------- 1 | import boto.ses 2 | 3 | def sendmail(from_addr, region='us-east-1'): 4 | conn = boto.ses.connect_to_region(region) 5 | def sendmail(to_addr, subject, body): 6 | conn.send_email(from_addr, subject, body, [to_addr]) 7 | return sendmail 8 | 9 | def from_env(): 10 | import os 11 | return sendmail(os.environ['SES'], 12 | os.environ.get('AWS_REGION', 'us-east-1')) 13 | -------------------------------------------------------------------------------- /server/twotieredkanban/site.py: -------------------------------------------------------------------------------- 1 | import BTrees.OOBTree 2 | import persistent 3 | import zc.generationalset 4 | from ZODB.utils import u64 5 | 6 | from .board import Board 7 | 8 | def get_site(root, name, title=None): 9 | try: 10 | sites = root.sites 11 | except AttributeError: 12 | if title: 13 | sites = root.sites = BTrees.OOBTree.BTree() 14 | else: 15 | raise 16 | 17 | try: 18 | return sites[name] 19 | except KeyError: 20 | if title: 21 | sites[name] = Site(title) 22 | return sites[name] 23 | else: 24 | raise 25 | 26 | class Site(persistent.Persistent): 27 | 28 | id = 'site' 29 | 30 | # List of users as seen by the client. This is a view on actial 31 | # user data managed by the auth plugin. It's assumed that the 32 | # number of users is limited. 33 | users = () 34 | auth = None 35 | title = '' 36 | 37 | def __init__(self, title): 38 | self.title = title 39 | self.boards = BTrees.OOBTree.BTree() 40 | self.changes = changes = zc.generationalset.GSet() 41 | self.changes.add(self) 42 | 43 | def json_reduce(self): 44 | return dict( 45 | users=self.users, 46 | boards=[dict(name=board.name, 47 | title=board.title, 48 | description=board.description) 49 | for board in self.boards.values()], 50 | ) 51 | 52 | def _changed(self): 53 | self.changes.add(self) 54 | for board in self.boards.values(): 55 | board.site_changed() 56 | 57 | def update_users(self, users): 58 | self.users = list(users) 59 | self._changed() 60 | 61 | def add_board(self, name, title='', description=''): 62 | if name in self.boards: 63 | raise KeyError(name) 64 | self.boards[name] = board = Board(self, name, title, description) 65 | self._changed() 66 | return board 67 | 68 | def rename(self, old, name): 69 | if old == name: 70 | return 71 | if name in self.boards: 72 | raise KeyError(name) 73 | board = self.boards.pop(old) 74 | board.name = name 75 | self.boards[name] = board 76 | self._changed() 77 | 78 | def updates(self, generation): 79 | updates = self.changes.generational_updates(generation) 80 | if len(updates) > 1: 81 | [site] = updates['adds'] 82 | updates = dict( 83 | site=site, 84 | generation=updates['generation'], 85 | ) 86 | if generation == 0: 87 | updates['zoid'] = str(u64(self.changes._p_oid)) 88 | 89 | return updates 90 | 91 | @property 92 | def generation(self): 93 | return self.changes.generation 94 | -------------------------------------------------------------------------------- /server/twotieredkanban/smtp.py: -------------------------------------------------------------------------------- 1 | import smtplib 2 | from email.mime.text import MIMEText 3 | 4 | def sendmail(from_, host, port=0, user=None, password=None, tls=None): 5 | port = int(port) 6 | 7 | def sendmail(to_addr, subject, body): 8 | msg = MIMEText(body) 9 | msg['Subject'] = subject 10 | msg['From'] = from_ 11 | msg['To'] = to_addr 12 | s = smtplib.SMTP('email-smtp.us-east-1.amazonaws.com', port) 13 | if tls: 14 | s.starttls() 15 | if user: 16 | s.login(user, password) 17 | s.send_message(msg) 18 | s.quit() 19 | 20 | return sendmail 21 | 22 | def from_env(): 23 | import os 24 | return sendmail( 25 | os.environ['SMTP'], 26 | os.environ['SMTP_HOST'], 27 | os.environ['SMTP_PORT'], 28 | os.environ['SMTP_USER'], 29 | os.environ['SMTP_PW'], 30 | os.environ['SMTP_TLS'], 31 | ) 32 | -------------------------------------------------------------------------------- /server/twotieredkanban/sql/__init__.py: -------------------------------------------------------------------------------- 1 | from contextlib import closing 2 | import os 3 | import re 4 | 5 | from newt.db import pg_connection 6 | import newt.qbe 7 | 8 | here = os.path.dirname(__file__) 9 | evolver = re.compile(r'evolve(\d+)$').match 10 | 11 | def evolve(dsn): 12 | with closing(pg_connection(dsn)) as conn: 13 | with closing(conn.cursor()) as cursor: 14 | try: 15 | with conn: 16 | cursor.execute("select version from ff_schema_version") 17 | [[version]] = cursor 18 | except Exception: 19 | with conn: 20 | cursor.execute(""" 21 | create table ff_schema_version(version int); 22 | insert into ff_schema_version values(0) 23 | """) 24 | version = 0 25 | 26 | for v, name, ob in sorted((int(evolver(name).group(1)), name, ob) 27 | for (name, ob) in globals().items() 28 | if evolver(name)): 29 | if v > version: 30 | print(name) 31 | with conn: 32 | if isinstance(ob, str): 33 | if ' ' not in ob: 34 | with open(os.path.join(here, ob)) as f: 35 | ob = f.read() 36 | cursor.execute(ob) 37 | else: 38 | ob(conn, cursor) 39 | cursor.execute( 40 | "update ff_schema_version set version = %s", 41 | (v,)) 42 | 43 | evolve1 = 'evolve1.sql' 44 | 45 | 46 | qbe = newt.qbe.QBE() 47 | 48 | qbe['text'] = newt.qbe.fulltext("extract_text(class_name, state)", "english") 49 | qbe['archived'] = newt.qbe.scalar("state -> 'history' -> -1 ->> 'archived'") 50 | qbe['modified'] = newt.qbe.scalar("state -> 'history' -> -1 ->> 'start'") 51 | qbe['board'] = newt.qbe.scalar("(state -> 'board' ->> '::=>')::bigint") 52 | -------------------------------------------------------------------------------- /server/twotieredkanban/sql/evolve1.sql: -------------------------------------------------------------------------------- 1 | create or replace function extract_text(class_name text, state jsonb) 2 | returns tsvector 3 | as $$ 4 | declare 5 | text text; 6 | v tsvector; 7 | r record; 8 | begin 9 | if state is null then return null; end if; 10 | 11 | text := coalesce(state ->> 'title', ''); 12 | v := setweight(to_tsvector('english', text), 'A'); 13 | 14 | text := coalesce(state ->> 'description', ''); 15 | v := v || setweight(to_tsvector('english', text), 'B'); 16 | 17 | if class_name = 'twotieredkanban.board.Task' then 18 | for r in select * from jsonb_array_elements(state->'task_texts') loop 19 | text := coalesce(r.value ->> 'title', ''); 20 | v := v || setweight(to_tsvector('english', text), 'B'); 21 | 22 | text := coalesce(r.value ->> 'description', ''); 23 | v := v || setweight(to_tsvector('english', text), 'C'); 24 | end loop; 25 | end if; 26 | 27 | return v; 28 | end 29 | $$ language plpgsql immutable cost 999; 30 | 31 | create index ff_text_idx on newt using gin ((extract_text(class_name, state))); 32 | 33 | create index ff_archived_idx on newt ((state -> 'history' -> -1 -> 'archived')); 34 | -------------------------------------------------------------------------------- /server/twotieredkanban/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feature-flow/twotieredkanban/d1c76ae1d578f39670ee744a4ef88e39b7957df5/server/twotieredkanban/tests/__init__.py -------------------------------------------------------------------------------- /server/twotieredkanban/tests/auth.py: -------------------------------------------------------------------------------- 1 | import bobo 2 | 3 | from .sample import users 4 | 5 | class NonAdmin: 6 | 7 | def user(self, request): 8 | return users[-1] 9 | 10 | class Admin: 11 | 12 | def user(self, request): 13 | return users[0] 14 | 15 | class Bad: 16 | 17 | def user(self, request): 18 | pass 19 | 20 | def login(self, request): 21 | return bobo.redirect('/auth/login') 22 | -------------------------------------------------------------------------------- /server/twotieredkanban/tests/sample.json: -------------------------------------------------------------------------------- 1 | ../../../client/tests/sample.json -------------------------------------------------------------------------------- /server/twotieredkanban/tests/sample.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | with open(os.path.splitext(__file__)[0] + '.json') as f: 5 | globals().update({k: tuple(v) for (k, v) in json.load(f).items()}) 6 | -------------------------------------------------------------------------------- /server/twotieredkanban/tests/testsearch.py: -------------------------------------------------------------------------------- 1 | """Test search-functionality 2 | 3 | These tests require a Postgres connection, because Postgres performes searches. 4 | """ 5 | import bobo 6 | from newt.db.tests.base import TestCase 7 | import pkg_resources 8 | from pprint import pprint 9 | from testvars import Vars 10 | import webtest 11 | 12 | from ..site import get_site 13 | 14 | from . import auth, sample 15 | 16 | db_config = """ 17 | %%import newt.db 18 | 19 | 20 | 21 | keep-history false 22 | 23 | 24 | dsn %s 25 | 26 | 27 | 28 | 29 | """ 30 | 31 | 32 | def make_app(dsn): 33 | app = bobo.Application( 34 | bobo_resources="twotieredkanban.apibase", 35 | bobo_configure="twotieredkanban.initializedb:config", 36 | bobo_handle_exceptions = False, 37 | dsn=dsn, 38 | ) 39 | return pkg_resources.load_entry_point( 40 | 'zc.zodbwsgi', 'paste.filter_app_factory', 'main')( 41 | app, {}, 42 | configuration = db_config % dsn, 43 | max_connections = '4', 44 | thread_transaction_manager = 'False', 45 | initializer='twotieredkanban.initializedb:initialize', 46 | ) 47 | 48 | 49 | class SearchTests(TestCase): 50 | 51 | maxDiff = None 52 | 53 | def setUp(self): 54 | super().setUp() 55 | self._app = make_app(self.dsn) 56 | with self._app.database.transaction() as conn: 57 | get_site(conn.root, 'localhost', 'Test site').auth = auth.Admin() 58 | 59 | self.app = self._test_app() 60 | self.vars = Vars() 61 | 62 | def _test_app(self): 63 | app = webtest.TestApp(self._app) 64 | app.extra_environ['HTTP_X_GENERATION'] = '0' 65 | return app 66 | 67 | def test_archive_where(self): 68 | from ..apiboard import archive_where 69 | with self._app.database.transaction() as conn: 70 | site = get_site(conn.root, 'localhost') 71 | self.assertEqual( 72 | b"((state -> 'history' -> -1 ->> 'archived') = 'true') AND\n" 73 | b" (((state -> 'board' ->> '::=>')::bigint) = 2)\n" 74 | b"ORDER BY (state -> 'history' -> -1 ->> 'start') DESC", 75 | archive_where(site)) 76 | self.assertEqual( 77 | b"((state -> 'history' -> -1 ->> 'archived') = 'true') AND\n" 78 | b" (((state -> 'board' ->> '::=>')::bigint) = 2) AND\n" 79 | b" extract_text(class_name, state) @@" 80 | b" to_tsquery('english', 'test')\n" 81 | b"ORDER BY ts_rank_cd(array[0.1, 0.2, 0.4, 1]," 82 | b" extract_text(class_name, state)," 83 | b" to_tsquery('english', 'test')) DESC", 84 | archive_where(site, 'test')) 85 | 86 | def test_archive_search(self): 87 | with self._app.database.transaction() as conn: 88 | site = get_site(conn.root, 'localhost') 89 | site.add_board('test', '', '') 90 | board = site.boards['test'] 91 | features = {} 92 | for task in sample.tasks: 93 | if not task.get('parent'): 94 | features[task['id']] = task['title'] 95 | board.new_project( 96 | task['title'], task['order'], task['description']) 97 | for feature_id in features: 98 | features[feature_id] = [task for task in board.tasks 99 | if task.title == features[feature_id] 100 | ][0] 101 | for task in sample.tasks: 102 | parent = task.get('parent') 103 | if parent: 104 | board.new_task( 105 | features[parent].id, 106 | task['title'], task['order'], task['description'], 107 | ) 108 | 109 | feature_ids = [f.id for f in sorted((f for f in features.values()), 110 | key=lambda f: f.title)] 111 | for feature_id in feature_ids: 112 | board.archive_feature(feature_id) 113 | 114 | r = self.app.get('/board/test/archive', dict(size=2)) 115 | self.assertEqual(4, r.json['count']) 116 | fvel, fauth = r.json['features'] 117 | self.assertEqual('Velocity', fvel['title']) 118 | from pprint import pprint 119 | self.assertEqual(['Create test based on example', 120 | 'Define model', 121 | 'Design velocity display', 122 | 'Develop example', 123 | 'Server API velocity support', 124 | 'Velocity display', 125 | 'demo+model test'], 126 | sorted(t['title'] for t in fvel['tasks'])) 127 | self.assertEqual('Users and authentication', fauth['title']) 128 | 129 | r = self.app.get('/board/test/archive', dict(start=2, size=2)) 130 | self.assertEqual(4, r.json['count']) 131 | fproto, fper = r.json['features'] 132 | self.assertEqual('Persistence', fper['title']) 133 | self.assertEqual('Prototype board', fproto['title']) 134 | 135 | r = self.app.get('/board/test/archive', dict(size=2, text='api')) 136 | self.assertEqual(3, r.json['count']) 137 | self.assertEqual(['Persistence', 'Prototype board'], 138 | [t['title'] for t in r.json['features']]) 139 | 140 | def test_archive_search_isolation(self): 141 | with self._app.database.transaction() as conn: 142 | site = get_site(conn.root, 'localhost') 143 | site.add_board('test', '', '') 144 | board = site.boards['test'] 145 | board.new_project('test', 0) 146 | board.archive_feature(list(board.tasks)[0].id) 147 | 148 | site.add_board('other', '', '') 149 | board = site.boards['other'] 150 | board.new_project('test other board', 0) 151 | [p] = board.tasks 152 | board.archive_feature(list(board.tasks)[0].id) 153 | 154 | site = get_site(conn.root, 'other', 'Other site') 155 | site.add_board('test', '', '') 156 | board = site.boards['test'] 157 | board.new_project('test other site', 0) 158 | board.archive_feature(list(board.tasks)[0].id) 159 | 160 | r = self.app.get('/board/test/archive', dict(size=99)) 161 | self.assertEqual(1, r.json['count']) 162 | [f] = r.json['features'] 163 | self.assertEqual('test', f['title']) 164 | 165 | r = self.app.get('/board/test/archive', dict(text='test')) 166 | [f] = r.json['features'] 167 | self.assertEqual('test', f['title']) 168 | -------------------------------------------------------------------------------- /server/twotieredkanban/tests/testsite.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from testvars import Vars 3 | import ZODB 4 | from ZODB.utils import u64 5 | 6 | from .sample import users 7 | 8 | class SiteTests(unittest.TestCase): 9 | 10 | def setUp(self): 11 | from ..site import Site 12 | self.site = Site("Test") 13 | self.generation = self.site.generation 14 | self.conn = ZODB.connection(None) 15 | self.conn.root.site = self.site 16 | self.conn.transaction_manager.commit() 17 | 18 | def updates(self): 19 | updates = self.site.updates(self.generation) 20 | self.generation = updates.pop('generation') 21 | return updates 22 | 23 | def test_new_site(self): 24 | self.assertEqual(self.site.title, "Test") 25 | self.assertEqual(self.site.users, ()) 26 | self.assertEqual(dict(self.site.boards), {}) 27 | self.assertEqual(self.site, self.site.updates(0)['site']) 28 | 29 | def test_add_board(self): 30 | vars = Vars() 31 | generation = self.site.generation 32 | self.site.add_board('first', 'The first one', 'Yup, the first') 33 | self.assertEqual([('first', vars.board)], 34 | list(self.site.boards.items())) 35 | self.assertEqual(self.site, vars.board.site) 36 | self.assertEqual('first', vars.board.name) 37 | self.assertEqual('The first one', vars.board.title) 38 | self.assertEqual('Yup, the first', vars.board.description) 39 | self.assertTrue(self.site.generation > generation) 40 | 41 | generation = vars.board.generation 42 | self.site.add_board('second', 'The second one', 'Yup, the second') 43 | self.assertEqual(['first', 'second'], list(self.site.boards)) 44 | 45 | # The original board was updated: 46 | self.assertTrue(vars.board.generation > generation) 47 | 48 | def test_rename_board(self): 49 | self.site.add_board('first', '', '') 50 | board = self.site.boards['first'] 51 | board_generation = board.generation 52 | self.updates() 53 | 54 | # renaming to same name is a noop: 55 | self.site.rename('first', 'first') 56 | self.assertEqual({}, self.updates()) 57 | self.assertEqual(board_generation, board.generation) 58 | 59 | self.site.rename('first', 'fist') 60 | self.assertEqual(self.site, self.updates()['site']) 61 | self.assertEqual([dict(name='fist', title='', description='')], 62 | self.site.json_reduce()['boards']) 63 | self.assertEqual(board, board.updates(board_generation)['board']) 64 | self.assertEqual('fist', board.name) 65 | 66 | def test_update_users(self): 67 | self.site.add_board('first', 'The first one', 'Yup, the first') 68 | board = self.site.boards['first'] 69 | site_generation = self.site.generation 70 | board_generation = board.generation 71 | self.site.update_users(users) 72 | self.assertEqual(list(users), self.site.users) 73 | self.assertTrue(self.site.generation > site_generation) 74 | self.assertTrue(board.generation > board_generation) 75 | 76 | def test_json(self): 77 | self.site.add_board('first', 'The first one', 'Yup, the first') 78 | self.site.update_users(users) 79 | self.assertEqual( 80 | dict(users=list(users), 81 | boards=[dict(name='first', 82 | title='The first one', 83 | description='Yup, the first')]), 84 | self.site.json_reduce(), 85 | ) 86 | 87 | def test_site_updates(self): 88 | self.site.add_board('first', 'The first one', 'Yup, the first') 89 | self.site.update_users(users) 90 | 91 | self.assertEqual( 92 | {'generation': Vars().x, 93 | 'site': self.site, 'zoid': str(u64(self.site.changes._p_oid))}, 94 | self.site.updates(0)) 95 | -------------------------------------------------------------------------------- /server/twotieredkanban/tests/testtask.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | import unittest 3 | 4 | class TaskTests(unittest.TestCase): 5 | 6 | @mock.patch('twotieredkanban.board.now') 7 | def test_creation(self, now): 8 | from ..board import State, Task 9 | 10 | now.return_value = '2017-06-08T10:02:00.004' 11 | import uuid 12 | id = uuid.uuid1() 13 | with mock.patch('uuid.uuid1', return_value=id): 14 | task = Task(self, 'the task', 42, 'the description', 15 | state = State(1, 'test')) 16 | 17 | self.assertEqual(self, task.board) 18 | 19 | self.assertEqual( 20 | dict(id = id.hex, 21 | title = 'the task', 22 | description = 'the description', 23 | order = 42, 24 | state = 'test', 25 | parent = None, 26 | blocked = None, 27 | assigned = None, 28 | size = 1, 29 | history=( 30 | dict(start='2017-06-08T10:02:00.004', state='test'), 31 | ), 32 | ), 33 | task.json_reduce()) 34 | 35 | task.tasks = [] 36 | self.assertEqual( 37 | dict(id = id.hex, 38 | title = 'the task', 39 | description = 'the description', 40 | order = 42, 41 | state = 'test', 42 | parent = None, 43 | blocked = None, 44 | assigned = None, 45 | size = 1, 46 | history=( 47 | dict(start='2017-06-08T10:02:00.004', state='test'), 48 | ), 49 | tasks = [], 50 | ), 51 | task.json_reduce()) 52 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | name, version = 'twotieredkanban', '0' 2 | 3 | install_requires = [ 4 | 'bleach', 5 | 'bobo', 6 | 'gevent', 'psycogreen', 7 | 'jinja2', 8 | 'PyJWT', 9 | 'setuptools', 10 | 'zc.generationalset', 11 | 'zope.exceptions', # XXX required by zodbwsgi 12 | 'passlib', 13 | 'newt.db', 14 | 'newt.qbe', 15 | 'email_validator', 16 | ] 17 | extras_require = dict( 18 | test=['zope.testing', 'webtest', 'zc.zodbwsgi', 'testvars'], 19 | ses=['boto'], 20 | raven=['raven', 'j1m.ravenzconfig'], 21 | ) 22 | 23 | entry_points = """ 24 | [zc.buildout] 25 | default = twotieredkanban.kbrecipe:Recipe 26 | 27 | [paste.server_runner] 28 | main = twotieredkanban.server:runner 29 | 30 | [console_scripts] 31 | emailpw-bootstrap = twotieredkanban.emailpw:bootstrap_script 32 | """ 33 | 34 | from setuptools import setup 35 | 36 | long_description=open('README.rst').read() 37 | 38 | setup( 39 | author = 'Jim Fulton', 40 | author_email = 'jim@zope.com', 41 | license = 'ZPL 2.1', 42 | 43 | name = name, version = version, 44 | long_description = long_description, 45 | description = long_description.strip().split('\n')[1], 46 | packages = [name, name], 47 | package_dir = {'': 'server'}, 48 | install_requires = install_requires, 49 | zip_safe = False, 50 | entry_points=entry_points, 51 | package_data = {name: ['*.txt', '*.test', '*.html']}, 52 | extras_require = extras_require, 53 | tests_require = extras_require['test'], 54 | test_suite = name+'.tests.test_suite', 55 | ) 56 | -------------------------------------------------------------------------------- /stage-build: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | sudo yum -y install cleanpython26 4 | 5 | cd `dirname $0` 6 | /opt/cleanpython26/bin/python bootstrap.py 7 | 8 | bin/buildout -Uc rpm.cfg 9 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Fri May 15 2015 10:50:23 GMT-0400 (EDT) 3 | 4 | module.exports = function(config) { 5 | config.set({ 6 | 7 | // base path that will be used to resolve all patterns (eg. files, exclude) 8 | basePath: '', 9 | 10 | 11 | // frameworks to use 12 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 13 | frameworks: ['jasmine'], 14 | 15 | 16 | // list of files / patterns to load in the browser 17 | files: [ 18 | "static/angular/angular.js", 19 | "node_modules/angular-mocks/angular-mocks.js", 20 | "static/angular-animate/angular-animate.js", 21 | "static/angular-aria/angular-aria.js", 22 | "static/angular-material/angular-material.js", 23 | "static/angular-material-icons/angular-material-icons.js", 24 | "static/angular-sanitize/angular-sanitize.js", 25 | 'static/*.js' 26 | ], 27 | 28 | 29 | // list of files to exclude 30 | exclude: [ 31 | ], 32 | 33 | 34 | // preprocess matching files before serving them to the browser 35 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 36 | preprocessors: { 37 | 'static/*.js': ['sourcemap'] 38 | }, 39 | 40 | 41 | // test results reporter to use 42 | // possible values: 'dots', 'progress' 43 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 44 | reporters: ['progress'], 45 | 46 | 47 | // web server port 48 | port: 9876, 49 | 50 | 51 | // enable / disable colors in the output (reporters and logs) 52 | colors: false, 53 | 54 | 55 | // level of logging 56 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 57 | logLevel: config.LOG_INFO, 58 | 59 | 60 | // enable / disable watching file and executing tests whenever any file changes 61 | autoWatch: true, 62 | 63 | 64 | // start these browsers 65 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 66 | browsers: ['Chrome'], 67 | 68 | 69 | // Continuous Integration mode 70 | // if true, Karma captures browsers, runs the tests and exits 71 | singleRun: false 72 | }); 73 | }; 74 | -------------------------------------------------------------------------------- /versions.cfg: -------------------------------------------------------------------------------- 1 | [versions] 2 | BTrees = 4.4.1 3 | Paste = 2.0.3 4 | PasteDeploy = 1.5.2 5 | PyJWT = 1.5.0 6 | RelStorage = 2.1a2 7 | WebOb = 1.7.2 8 | WebTest = 2.0.27 9 | ZConfig = 3.1.0 10 | ZEO = 5.1.0 11 | ZODB = 5.2.4 12 | beautifulsoup4 = 4.6.0 13 | bleach = 2.0.0 14 | bobo = 2.4.0 15 | cffi = 1.10.0 16 | collective.recipe.cmd = 0.11 17 | gevent = 1.2.2 18 | greenlet = 0.4.12 19 | html5lib = 0.999999999 20 | jinja2 = 2.9.6 21 | manuel = 1.8.0 22 | mock = 2.0.0 23 | newt.db = 0.8.0 24 | newt.qbe = 0.1.1 25 | passlib = 1.7.1 26 | pbr = 3.0.1 27 | perfmetrics = 2.0 28 | persistent = 4.2.4.2 29 | psycogreen = 1.0 30 | psycopg2 = 2.7.5 31 | pycparser = 2.17 32 | repoze.retry = 1.4 33 | six = 1.10.0 34 | transaction = 2.1.2 35 | waitress = 1.0.2 36 | webencodings = 0.5.1 37 | zc.generationalset = 0.4.0 38 | zc.lockfile = 1.2.1 39 | zc.recipe.deployment = 1.3.0 40 | zc.recipe.egg = 2.0.3 41 | zc.recipe.testrunner = 2.0.0 42 | zc.wsgirunner = 0.1.0 43 | zc.zdaemonrecipe = 1.0.0 44 | zc.zodbwsgi = 1.2.0 45 | zdaemon = 4.2.0 46 | zodbpickle = 0.6.0 47 | zope.exceptions = 4.1.0 48 | zope.interface = 4.4.2 49 | zope.testing = 4.6.2 50 | zope.testrunner = 4.7.0 51 | -------------------------------------------------------------------------------- /webpack: -------------------------------------------------------------------------------- 1 | ./node_modules/webpack/bin/webpack.js $@ 2 | -------------------------------------------------------------------------------- /webpack.config-test.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | const path = require('path'); 3 | var nodeExternals = require('webpack-node-externals'); 4 | 5 | module.exports = { 6 | target: 'node', 7 | externals: [nodeExternals()], 8 | resolve: { 9 | modules: ['node_modules'], 10 | alias: { 11 | indexedDB: 'fake-indexeddb', 12 | Sample: '../tests/sample.json' 13 | }, 14 | extensions: ['.js', '.jsx', '.css', 'json'] 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.js$/, 20 | exclude: /(node_modules|bower_components)/, 21 | use: [ 22 | { 23 | loader: 'babel-loader', 24 | options: { 25 | presets: ['react', 'es2015'] 26 | } 27 | } 28 | ] 29 | }, 30 | { test: /\.json$/, use: ["json-loader"] } 31 | ] 32 | }, 33 | output: { 34 | // sourcemap support for IntelliJ/Webstorm 35 | devtoolModuleFilenameTemplate: '[absolute-resource-path]', 36 | devtoolFallbackModuleFilenameTemplate: '[absolute-resource-path]?[hash]' 37 | }, 38 | devtool: "cheap-module-source-map" 39 | }; 40 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | const path = require('path'); 3 | 4 | module.exports = function (env) { 5 | const config = { 6 | entry: [ 7 | './client/app.jsx' 8 | ], 9 | output: { 10 | path: __dirname, 11 | filename: './static/bundle.js' 12 | }, 13 | resolve: { 14 | modules: ['node_modules'], 15 | alias: { 16 | BoardAPI: path.resolve(__dirname, 'client/model/boardapi'), 17 | SiteAPI: path.resolve(__dirname, 'client/model/siteapi'), 18 | AuthUI: path.resolve(__dirname, 'client/emailpw/ui'), 19 | Intro: path.resolve(__dirname, 'client/ui/intro') 20 | }, 21 | extensions: ['.js', '.jsx', '.css'] 22 | }, 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.jsx?$/, 27 | exclude: /(node_modules|bower_components)/, 28 | use: [ 29 | { 30 | loader: 'babel-loader', 31 | options: { 32 | presets: ['react', 'es2015'] 33 | } 34 | } 35 | ] 36 | }, 37 | { 38 | test: /\.css$/, 39 | use: [ 40 | "style-loader", 41 | { 42 | loader: "css-loader", 43 | options: { 44 | modules: true, 45 | sourceMap: true, 46 | importLoaders: 1, 47 | localIdentName: "[name]--[local]--[hash:base64:8]" 48 | } 49 | }, 50 | // has separate config, see postcss.config.js nearby 51 | "postcss-loader" 52 | ] 53 | }, 54 | { 55 | test: /\.scss$/, 56 | use: ["style-loader", "css-loader", "sass-loader" ] 57 | }, 58 | { 59 | test: /\.html$/, 60 | use: ["html-loader"] 61 | }, 62 | { test: /\.json$/, use: ["json-loader"] } 63 | ] 64 | }, 65 | devtool: 'cheap-module-eval-source-map' 66 | }; 67 | 68 | if (env && env.prod) { 69 | config.devtool = 'source-map'; 70 | config.plugins = [ 71 | new webpack.DefinePlugin({ 72 | 'process.env': { 73 | NODE_ENV: JSON.stringify('production') 74 | } 75 | }), 76 | new webpack.optimize.UglifyJsPlugin({sourceMap: true}) 77 | ]; 78 | } 79 | 80 | if (env && env.demo) { 81 | if (env.prod) { 82 | config.output.filename = './prod/bundle.js'; 83 | } 84 | else { 85 | config.output.filename = './demo/static/bundle.js'; 86 | } 87 | config.resolve.alias = { 88 | indexedDB: path.resolve(__dirname, 'client/demo/indexeddb'), 89 | BoardAPI: path.resolve(__dirname, 'client/demo/boardapi'), 90 | SiteAPI: path.resolve(__dirname, 'client/demo/siteapi'), 91 | AuthUI: path.resolve(__dirname, 'client/demo/ui'), 92 | Intro: path.resolve(__dirname, 'client/demo/intro'), 93 | Sample: path.resolve(__dirname, 'client/demo/sample.json') 94 | }; 95 | } 96 | 97 | return config; 98 | }; 99 | --------------------------------------------------------------------------------