From fdabc6a32b6c0295d174e2477876c947e0ce02b9 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Sep 28 2017 17:33:46 +0000
Subject: [PATCH 1/20] Migrate to webpack 2+


---

diff --git a/hubs/static/client/.babelrc b/hubs/static/client/.babelrc
index aef231b..25043d0 100644
--- a/hubs/static/client/.babelrc
+++ b/hubs/static/client/.babelrc
@@ -1,5 +1,5 @@
 {
-  "presets" : ["es2015", "react", "stage-1"],
-  "plugins": ["transform-class-properties"]
+  "presets" : ["es2015", "react"],
+  "plugins": ["syntax-dynamic-import"]
 }
 
diff --git a/hubs/static/client/.eslintrc.json b/hubs/static/client/.eslintrc.json
index 434bab5..86fbafe 100644
--- a/hubs/static/client/.eslintrc.json
+++ b/hubs/static/client/.eslintrc.json
@@ -1,4 +1,8 @@
 {
+  "parser": "babel-eslint",
+  "plugins": [
+    "react"
+  ],
   "rules": {
     "arrow-body-style": "off",
     "func-names": "off",
diff --git a/hubs/static/client/package.json b/hubs/static/client/package.json
index 504071d..553d1c1 100644
--- a/hubs/static/client/package.json
+++ b/hubs/static/client/package.json
@@ -1,36 +1,39 @@
 {
   "name": "fedora-hubs",
-  "version": "0.0.0",
+  "version": "0.1.0",
   "description": "Fedora Hubs will provide a communication and collaboration center for Fedora contributors of all types. The idea is that contributors will be able to visit Hubs to check on their involvements across Fedora, discover new places that they can contribute, and more.",
   "main": "webpack.config.js",
   "directories": {
     "doc": "docs"
   },
   "dependencies": {
-    "prop-types": "^15.5.8",
-    "react": "^15.4.0",
-    "react-autosuggest": "~6.1.0",
-    "react-dom": "^15.4.0",
-    "react-intl": "~2.1.5",
-    "react-linkify": "^0.1.3",
-    "react-timeago": "^3.1.1",
+    "prop-types": "^15.5.10",
+    "react": "^15.6.1",
+    "react-autosuggest": "^9.3.2",
+    "react-dom": "^15.6.1",
+    "react-intl": "^2.3.0",
+    "react-linkify": "^0.2.1",
+    "react-loadable": "^4.0.4",
+    "react-timeago": "^3.4.3",
     "reconnecting-eventsource": "^1.0.1"
   },
   "devDependencies": {
-    "babel-loader": "~6.2.4",
-    "babel-plugin-transform-class-properties": "^6.10.2",
-    "babel-preset-es2015": "~6.9.0",
-    "babel-preset-react": "~6.5.0",
-    "babel-jest": "^14.1.0",
-    "babel-preset-es2015": "^6.9.0",
-    "babel-preset-jest": "^14.1.0",
-    "babel-preset-react": "^6.5.0",
-    "babel-preset-stage-1": "^6.5.0",
-    "eslint": "^2.13.1",
-    "jest": "^14.1.0",
-    "react-addons-test-utils": "^15.4.0",
-    "react-test-renderer": "^15.4.0",
-    "webpack": "~1.13.1"
+    "babel-core": "^6.26.0",
+    "babel-eslint": "^7.2.3",
+    "babel-jest": "^20.0.3",
+    "babel-loader": "^7.1.1",
+    "babel-plugin-syntax-dynamic-import": "^6.18.0",
+    "babel-preset-es2015": "^6.24.1",
+    "babel-preset-react": "^6.24.1",
+    "css-loader": "^0.28.4",
+    "eslint": "^4.4.1",
+    "eslint-loader": "^1.9.0",
+    "eslint-plugin-react": "^7.2.1",
+    "file-loader": "^0.11.2",
+    "jest": "^20.0.4",
+    "react-test-renderer": "^15.6.1",
+    "style-loader": "^0.18.2",
+    "webpack": "^3.5.5"
   },
   "scripts": {
     "dev": "webpack --watch",
diff --git a/hubs/static/client/webpack.config.js b/hubs/static/client/webpack.config.js
index 532e978..efbe75b 100644
--- a/hubs/static/client/webpack.config.js
+++ b/hubs/static/client/webpack.config.js
@@ -23,14 +23,41 @@ const config = {
         'react-dom': 'ReactDOM',
     },
     module : {
-        loaders : [{
+        rules: [
+        {
+            test: /\.jsx?$/,
+            enforce: 'pre',
+            loader: 'eslint-loader',
+            options: {
+                emitWarning: true,
+            },
+            include : PATHS.app
+        },
+        {
             test : /\.jsx?$/,
-            loader : 'babel',
-            exclude: /(node_modules|bowercomponents)/,
+            loader : 'babel-loader',
+            options: {
+                cacheDirectory: true,
+            },
+            exclude: /node_modules/,
             include : PATHS.app
-        }]
+        },
+        {
+            test: /\.css$/,
+            use: [
+                'style-loader',
+                'css-loader'
+            ]
+        },
+        {
+            test: /\.(png|svg|jpg|gif)$/,
+            use: [ 'file-loader' ]
+        }
+        ]
     },
-    plugins: [ new webpack.optimize.CommonsChunkPlugin("common.js") ]
+    plugins: [
+        new webpack.optimize.CommonsChunkPlugin("common")
+    ]
 };
 
 module.exports = config;

From a22209d3c3ecfb9df4013a1906e6fc9fcd150d1d Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Sep 28 2017 17:33:46 +0000
Subject: [PATCH 2/20] Reformat spaces


---

diff --git a/hubs/static/client/webpack.config.js b/hubs/static/client/webpack.config.js
index efbe75b..c308851 100644
--- a/hubs/static/client/webpack.config.js
+++ b/hubs/static/client/webpack.config.js
@@ -2,62 +2,62 @@ const webpack = require('webpack');
 const path = require('path');
 
 const PATHS = {
-    app: path.join(__dirname, 'app'),
-    build: path.join(__dirname, '..', 'js', 'build')
+  app: path.join(__dirname, 'app'),
+  build: path.join(__dirname, '..', 'js', 'build')
 };
 
 const config = {
-    entry: {
-        Hubs: path.join(PATHS.app, 'core', 'Hubs.js'),
-        Feed: path.join(PATHS.app, 'widgets', 'feed', 'Feed.js'),
-        Halp: path.join(PATHS.app, 'widgets', 'halp', 'Halp.js')
+  entry: {
+    Hubs: path.join(PATHS.app, 'core', 'Hubs.js'),
+    Feed: path.join(PATHS.app, 'widgets', 'feed', 'Feed.js'),
+    Halp: path.join(PATHS.app, 'widgets', 'halp', 'Halp.js')
+  },
+  output: {
+    path: PATHS.build,
+    filename: '[name].js',
+    library: '[name]',
+  },
+  externals: {
+    'jquery': 'jQuery',
+    'react': 'React',
+    'react-dom': 'ReactDOM',
+  },
+  module: {
+    rules: [
+    {
+      test: /\.jsx?$/,
+      enforce: 'pre',
+      loader: 'eslint-loader',
+      options: {
+        emitWarning: true,
+      },
+      include : PATHS.app
     },
-    output: {
-        path: PATHS.build,
-        filename: '[name].js',
-        library: '[name]',
+    {
+      test : /\.jsx?$/,
+      loader : 'babel-loader',
+      options: {
+        cacheDirectory: true,
+      },
+      exclude: /node_modules/,
+      include : PATHS.app
     },
-    externals: {
-        'jquery': 'jQuery',
-        'react': 'React',
-        'react-dom': 'ReactDOM',
+    {
+      test: /\.css$/,
+      use: [
+        'style-loader',
+        'css-loader'
+      ]
     },
-    module : {
-        rules: [
-        {
-            test: /\.jsx?$/,
-            enforce: 'pre',
-            loader: 'eslint-loader',
-            options: {
-                emitWarning: true,
-            },
-            include : PATHS.app
-        },
-        {
-            test : /\.jsx?$/,
-            loader : 'babel-loader',
-            options: {
-                cacheDirectory: true,
-            },
-            exclude: /node_modules/,
-            include : PATHS.app
-        },
-        {
-            test: /\.css$/,
-            use: [
-                'style-loader',
-                'css-loader'
-            ]
-        },
-        {
-            test: /\.(png|svg|jpg|gif)$/,
-            use: [ 'file-loader' ]
-        }
-        ]
-    },
-    plugins: [
-        new webpack.optimize.CommonsChunkPlugin("common")
+    {
+      test: /\.(png|svg|jpg|gif)$/,
+      use: [ 'file-loader' ]
+    }
     ]
+  },
+  plugins: [
+    new webpack.optimize.CommonsChunkPlugin("common")
+  ]
 };
 
 module.exports = config;

From a79f9cd88ada2554b6a8e0c8b52fd156e8d89319 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Sep 28 2017 17:35:08 +0000
Subject: [PATCH 3/20] Make auth and user change more robust


---

diff --git a/hubs/app.py b/hubs/app.py
index 8974ae4..56173d1 100644
--- a/hubs/app.py
+++ b/hubs/app.py
@@ -71,8 +71,8 @@ def check_auth():
         # request.
         user_sub = OIDC.user_getfield("sub")
         if ("auth" not in flask.session
-            or not flask.session["auth"]
-            or flask.session["auth"]["sub"] != user_sub):
+                or not flask.session["auth"]
+                or flask.session["auth"].get("sub") != user_sub):
             user_info = OIDC.user_getinfo([
                 "name", "nickname", "sub", "email", "zoneinfo", "cla",
                 "groups",
diff --git a/hubs/tests/test_authn.py b/hubs/tests/test_authn.py
index 350edf3..1ff17c1 100644
--- a/hubs/tests/test_authn.py
+++ b/hubs/tests/test_authn.py
@@ -3,7 +3,7 @@ from __future__ import absolute_import, unicode_literals
 import flask
 
 import hubs.app
-from hubs.tests import APPTest, FakeAuthorization
+from hubs.tests import APPTest
 
 
 class AuthnTestCase(APPTest):
@@ -23,7 +23,6 @@ class AuthnTestCase(APPTest):
 
     def test_expired_auth(self):
         # Previous user is still in the session
- 
         flask.session["auth"] = dict(
             nickname="olduser",
             name="olduser",

From ddbc205ba1704675024ad2503d01f5d8c7c4205e Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Sep 28 2017 17:35:08 +0000
Subject: [PATCH 4/20] Make auth testing closer to real auth


---

diff --git a/hubs/tests/__init__.py b/hubs/tests/__init__.py
index 15051a6..fddf404 100644
--- a/hubs/tests/__init__.py
+++ b/hubs/tests/__init__.py
@@ -141,7 +141,7 @@ def auth_set(APP, auth):
             g.auth = munch.Munch(logged_in=False)
             g.user = None
         else:
-            g.user = auth.user
+            g.user = FakeUser(auth.nickname)
 
     if flask._app_ctx_stack.top is None:
         # App context isn't pushed yet
@@ -162,25 +162,19 @@ class FakeUser(object):
             supposed to be.
         """
         self.username = username
-        self.booksmarks = []
+        self.bookmarks = []
         self.groups = []
 
 
-class FakeAuthorization(object):
+def FakeAuthorization(username):
     """ Fake Authorization used to set as flask.g.auth. """
-
-    def __init__(self, username='username'):
-        """ Constructor.
-        :arg groups: list of the groups in which this fake user is
-            supposed to be.
-        """
-        self.logged_in = True
-        self.fullname = 'fullname: ' + username
-        self.email = 'email: ' + username
-        self.user = FakeUser(username)
-        self.avatar = 'avatar_src_url'
-        self.nickname = username
-        self.username = username
+    return munch.Munch(
+        logged_in=True,
+        fullname='fullname: ' + username,
+        nickname=username,
+        email='email: ' + username,
+        avatar='avatar_src_url',
+    )
 
 
 @contextmanager
diff --git a/hubs/tests/test_authz.py b/hubs/tests/test_authz.py
index 7a7c85a..f47b85f 100644
--- a/hubs/tests/test_authz.py
+++ b/hubs/tests/test_authz.py
@@ -2,7 +2,7 @@ from __future__ import absolute_import, unicode_literals
 
 from hubs.app import app
 from hubs.authz import ObjectAuthzMixin, AccessLevel, PERMISSIONS
-from hubs.tests import APPTest, FakeAuthorization
+from hubs.tests import APPTest, FakeUser
 
 
 class TestObj(ObjectAuthzMixin):
@@ -35,25 +35,25 @@ class AuthzTestCase(APPTest):
 
     def test_mixin_access_level_logged_in(self):
         obj = TestObj()
-        user = FakeAuthorization('ralph')
+        user = FakeUser('ralph')
         self.assertEqual(
             obj._get_auth_access_level(user), AccessLevel.logged_in)
 
     def test_mixin_group_access_level(self):
         obj = TestObj(roles={"auth-group": "member"})
-        user = FakeAuthorization('ralph')
+        user = FakeUser('ralph')
         self.assertEqual(
             obj._get_auth_access_level(user), AccessLevel.member)
 
     def test_mixin_group_access_level_no_role(self):
         obj = TestObj(roles={"unrelated-group": "member"})
-        user = FakeAuthorization('ralph')
+        user = FakeUser('ralph')
         self.assertEqual(
             obj._get_auth_access_level(user), AccessLevel.logged_in)
 
     def test_mixin_group_access_level_unsupported(self):
         obj = TestObj(roles={"auth-group": "dark_lord"})
-        user = FakeAuthorization('ralph')
+        user = FakeUser('ralph')
         self.assertEqual(
             obj._get_auth_access_level(user), AccessLevel.logged_in)
 
@@ -68,7 +68,7 @@ class AuthzTestCase(APPTest):
         self.assertFalse(obj.allows(None, 'widget.restricted.view'))
 
     def test_allows_logged_in(self):
-        user = FakeAuthorization('ralph')
+        user = FakeUser('ralph')
         obj = TestObj()
         self.assertTrue(obj.allows(user, 'hub.public.view'))
         self.assertTrue(obj.allows(user, 'hub.preview.view'))
@@ -79,7 +79,7 @@ class AuthzTestCase(APPTest):
         self.assertTrue(obj.allows(user, 'widget.restricted.view'))
 
     def test_allows_member(self):
-        user = FakeAuthorization('ralph')
+        user = FakeUser('ralph')
         obj = TestObj(roles={"auth-group": "member"})
         self.assertTrue(obj.allows(user, 'hub.public.view'))
         self.assertTrue(obj.allows(user, 'hub.preview.view'))
@@ -90,7 +90,7 @@ class AuthzTestCase(APPTest):
         self.assertTrue(obj.allows(user, 'widget.restricted.view'))
 
     def test_allows_sponsor(self):
-        user = FakeAuthorization('ralph')
+        user = FakeUser('ralph')
         obj = TestObj(roles={"auth-group": "sponsor"})
         self.assertTrue(obj.allows(user, 'hub.public.view'))
         self.assertTrue(obj.allows(user, 'hub.preview.view'))
@@ -101,7 +101,7 @@ class AuthzTestCase(APPTest):
         self.assertTrue(obj.allows(user, 'widget.restricted.view'))
 
     def test_allows_owner(self):
-        user = FakeAuthorization('ralph')
+        user = FakeUser('ralph')
         obj = TestObj(roles={"auth-group": "owner"})
         self.assertTrue(obj.allows(user, 'hub.public.view'))
         self.assertTrue(obj.allows(user, 'hub.preview.view'))
@@ -112,7 +112,7 @@ class AuthzTestCase(APPTest):
         self.assertTrue(obj.allows(user, 'widget.restricted.view'))
 
     def test_allows_site_admin(self):
-        user = FakeAuthorization('admin')
+        user = FakeUser('admin')
         obj = TestObj()
         for perm in PERMISSIONS.keys():
             self.assertTrue(obj.allows(user, perm))
diff --git a/hubs/tests/views/test_user.py b/hubs/tests/views/test_user.py
index 127fd36..eb1ed6a 100644
--- a/hubs/tests/views/test_user.py
+++ b/hubs/tests/views/test_user.py
@@ -50,7 +50,7 @@ class TestGetNotifications(hubs.tests.APPTest):
     def test_get_notifications_valid_name(self):
         with hubs.tests.auth_set(app, self.user):
             resp = self.app.get('/stream/saved/'.format(
-                self.user.username))
+                self.user.nickname))
 
         self.assertEqual(resp.status_code, 200)
         data = json.loads(resp.get_data(as_text=True))
@@ -63,7 +63,7 @@ class TestGetNotifications(hubs.tests.APPTest):
 class TestPostNotifications(hubs.tests.APPTest):
     user = hubs.tests.FakeAuthorization('ralph')
     valid_payload = {
-        'username': user.username,
+        'username': user.nickname,
         'markup': 'foobar',
         'link': 'baz',
         'secondary_icon': 'http://placekitten.com/g/200/300',
@@ -71,7 +71,7 @@ class TestPostNotifications(hubs.tests.APPTest):
     }
 
     invalid_payload = {
-        'username': user.username,
+        'username': user.nickname,
     }
 
     def test_post_notification_invalid_payload(self):
@@ -99,7 +99,7 @@ class TestPostNotifications(hubs.tests.APPTest):
         self.assertEqual(notification['link'], 'baz')
 
         all_saved = hubs.models.SavedNotification.by_username(
-            self.user.username)
+            self.user.nickname)
         self.assertEqual(len(all_saved), 2)
         all_saved = [s.__json__() for s in all_saved]
         self.assertTrue(any(str(s['markup']) == self.valid_payload['markup']
@@ -140,7 +140,7 @@ class TestDeleteNotifications(hubs.tests.APPTest):
 
         with hubs.tests.auth_set(app, self.user):
             resp = self.app.delete(
-                '/stream/saved/{}/'.format(self.user.username, idx)
+                '/stream/saved/{}/'.format(self.user.nickname, idx)
             )
         self.assertEqual(resp.status_code, 404)
 

From f563d019796d8b536d1be0acfd31a47e8c6a232c Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Sep 28 2017 17:35:08 +0000
Subject: [PATCH 5/20] Use functions for widget parameter validators


---

diff --git a/hubs/tests/test_widget_validators.py b/hubs/tests/test_widget_validators.py
index ba0c2b8..d421aa5 100644
--- a/hubs/tests/test_widget_validators.py
+++ b/hubs/tests/test_widget_validators.py
@@ -10,50 +10,50 @@ from hubs.tests import APPTest
 class ValidatorsTest(APPTest):
 
     def test_required(self):
-        self.assertRaises(ValueError, validators.Required.from_string, "")
+        self.assertRaises(ValueError, validators.Required, "")
 
     def test_text(self):
-        self.assertEqual(validators.Text.from_string("\xe9"), "\xe9")
+        self.assertEqual(validators.Text("\xe9"), "\xe9")
 
     def test_integer(self):
-        self.assertEqual(validators.Integer.from_string("1"), 1)
-        self.assertRaises(ValueError, validators.Integer.from_string, "text")
+        self.assertEqual(validators.Integer("1"), 1)
+        self.assertRaises(ValueError, validators.Integer, "text")
 
     @unittest.skip("Not implemented yet")
     def test_link(self):
         value = '<a href="somewhere">dummy</a>'
         self.assertEqual(validators.Link.from_string(value), value)
-        self.assertRaises(ValueError, validators.Link.from_string, "text")
+        self.assertRaises(ValueError, validators.Link, "text")
 
     def test_username(self):
-        self.assertEqual(validators.Username.from_string("ralph"), "ralph")
+        self.assertEqual(validators.Username("ralph"), "ralph")
         self.assertRaises(
-            ValueError, validators.Username.from_string, "nobody")
+            ValueError, validators.Username, "nobody")
 
     @unittest.skip("Not implemented yet")
     def test_github_organization(self):
         self.assertEqual(
-            validators.GithubOrganization.from_string("fedora-infra"),
+            validators.GithubOrganization("fedora-infra"),
             "fedora-infra")
         self.assertRaises(
             ValueError,
-            validators.GithubOrganization.from_string,
+            validators.GithubOrganization,
             "something-that-does-not-exist")
 
     @unittest.skip("Not implemented yet")
     def test_github_repo(self):
         self.assertEqual(
-            validators.GithubRepo.from_string("fedmsg"), "fedmsg")
-        self.assertRaises(ValueError, validators.GithubRepo.from_string,
+            validators.GithubRepo("fedmsg"), "fedmsg")
+        self.assertRaises(ValueError, validators.GithubRepo,
                           "something-that-does-not-exist")
 
     def test_fmncontext(self):
-        self.assertEqual(validators.FMNContext.from_string("email"), "email")
+        self.assertEqual(validators.FMNContext("email"), "email")
         self.assertRaises(
-            ValueError, validators.FMNContext.from_string, "dummy")
+            ValueError, validators.FMNContext, "dummy")
 
     def test_pagure_repo(self):
         self.assertEqual(
-            validators.PagureRepo.from_string("fedora-hubs"), "fedora-hubs")
-        self.assertRaises(ValueError, validators.PagureRepo.from_string,
+            validators.PagureRepo("fedora-hubs"), "fedora-hubs")
+        self.assertRaises(ValueError, validators.PagureRepo,
                           "something-that-does-not-exist")
diff --git a/hubs/widgets/base.py b/hubs/widgets/base.py
index 7f35b37..b562662 100644
--- a/hubs/widgets/base.py
+++ b/hubs/widgets/base.py
@@ -9,7 +9,6 @@ import six
 from importlib import import_module
 from .caching import CachedFunction
 from .view import WidgetView
-from .validators import Validator
 
 
 log = logging.getLogger(__name__)
@@ -25,18 +24,17 @@ class WidgetParameter(object):
     :py:meth:`~Widget.get_parameters` method.
 
     The value of the parameter is stored in the database as the value returned
-    by the validator's :py:meth:`from_string` method.  It can thus be a string,
-    an integer, a list, a dict, or any JSON-serializable value.
+    by the validator's call.  It can thus be a string, an integer, a list, a
+    dict, or any JSON-serializable value.
 
     Attributes:
         name (str): The name of the parameter.
         label (str): A humanized name of the parameter, which will be shown in
             the UI.
         default: The default value if this parameter is not set.
-        validator (hubs.widgets.validators.Validator): A validator subclass
-            that will be used to convert the parameter value to and from
-            string, raising an exception if it is invalid. This attribute
-            points to the validator subclass, not an instance of the class.
+        validator (callable): A validator function that will be used to convert
+            the parameter value to a JSON-serializable value, raising an
+            exception if it is invalid.
         help (str): A help text that will be shown in the UI.
     """
 
@@ -49,7 +47,7 @@ class WidgetParameter(object):
             raise TypeError("Invalid attribute: %s" % name)
         # Set default validator
         if self.validator is None:
-            self.validator = Validator
+            self.validator = lambda x: x
 
 
 class Widget(object):
diff --git a/hubs/widgets/validators.py b/hubs/widgets/validators.py
index bf05a53..f8c5b8f 100644
--- a/hubs/widgets/validators.py
+++ b/hubs/widgets/validators.py
@@ -1,82 +1,50 @@
+"""
+Validate and convert the value of widget parameters.
+
+Validators are used to validate and convert
+:py:class:`~hubs.widgets.base.WidgetParameter` values. They will raise a
+``ValueError`` exception if the value is invalid.
+
+A validator is a function that will receive the value as unique argument, and
+will return the validated value.
+"""
+
 from __future__ import unicode_literals
 
 import flask
 import hubs.models
 import kitchen.text.converters
 import requests
+import six
 
 
-class Validator(object):
-    """Convert widget parameters to and from string, and validate their value.
-
-    Validators are used to convert
-    :py:class:`~hubs.widgets.base.WidgetParameter` values to and from string.
-    They will raise an exception if the value is invalid.
-
-    A validator is a subclass of the :py:class:`Validator` class and implements
-    two class methods: :py:meth:`from_string` and :py:meth:`to_string`.
-    """
-
-    @classmethod
-    def from_string(cls, value):
-        """Convert the value from a string to a JSON-serializable value.
-
-        The result of this function will be stored in the database for widget
-        parameters.
-
-        Raises:
-            ValueError: The value is invalid.
-        """
-        if value is None:
-            return ""
-        return value
-
-    @classmethod
-    def to_string(cls, value):
-        """Convert the value to a string.
-
-        The result of this function will be used in the widget configuration
-        form fields.
-        """
-        return value
-
-
-class Required(Validator):
+def Required(value):
     """Raises an error if the value is ``False``-like."""
-
-    @classmethod
-    def from_string(cls, value):
-        if not bool(value):
-            raise ValueError("the parameter is required")
-        return value
+    if not bool(value):
+        raise ValueError("the parameter is required")
+    # if callable(value):
+    #     # Act as a decorator
+    #     return lambda v: Required(value(v))
+    return value
 
 
-class Text(Validator):
+def Text(value):
     """Raises an error if the value can't be converted to unicode."""
+    return kitchen.text.converters.to_unicode(value)
 
-    @classmethod
-    def from_string(cls, value):
-        return kitchen.text.converters.to_unicode(value)
 
-
-class Integer(Validator):
+def Integer(value):
     """Raises an error if the value can't be converted to an integer."""
-
-    @classmethod
-    def from_string(cls, value):
-        return int(value)
+    return int(value)
 
 
-class Link(Validator):
+def Link(value):
     """Raises an error if the value doesn't look like a link."""
-
-    @classmethod
-    def from_string(cls, value):
-        # TODO -- verify that this is actually a link
-        return value
+    # TODO -- verify that this is actually a link
+    return value
 
 
-class Username(Validator):
+def Username(value):
     """Raises an error if the value isn't an existing username.
 
     There must be a corresponding :py:class:`~hubs.models.User` record.
@@ -84,77 +52,55 @@ class Username(Validator):
     This validator does not return the User instance because it is not
     JSON-serializable, it returns the username unchanged.
     """
-
-    @classmethod
-    def from_string(cls, value):
-        if hubs.models.User.by_username(value) is not None:
-            return value
-        raise ValueError('Invalid username')
-
-    @classmethod
-    def to_string(cls, value):
-        if value is None and flask.g.auth.logged_in:
-            return flask.g.user.username
+    if value is None and flask.g.auth.logged_in:
+        return flask.g.user.username
+    if hubs.models.User.by_username(value) is not None:
         return value
+    raise ValueError('Invalid username')
 
 
-class GithubOrganization(Validator):
+def GithubOrganization(value):
     """Fails if the Github organization name does not exist."""
-
-    @classmethod
-    def from_string(cls, value):
-        # TODO -- implement this.
-        return value
+    # TODO -- implement this.
+    return value
 
 
-class GithubRepo(Validator):
+def GithubRepo(value):
     """Fails if the Github repository name does not exist."""
-
-    @classmethod
-    def from_string(cls, value):
-        # TODO -- implement this.
-        return value
+    # TODO -- implement this.
+    return value
 
 
-class FMNContext(Validator):
+def FMNContext(value):
     """Fails if the value is not a valid FMN context name."""
-
-    @classmethod
-    def from_string(cls, value):
-        # TODO get this from the fedmsg config.
-        if value in ['irc', 'email', 'android', 'desktop', 'hubs']:
-            return value
-        raise ValueError('Invalid FMN context')
+    # TODO get this from the fedmsg config.
+    if value in ['irc', 'email', 'android', 'desktop', 'hubs']:
+        return value
+    raise ValueError('Invalid FMN context')
 
 
-class PagureRepo(Validator):
+def PagureRepo(value):
     """Fails if the Pagure repository name does not exist."""
-
-    @classmethod
-    def from_string(cls, value):
-        response = requests.get("https://pagure.io/%s" % value, timeout=5)
-        if response.status_code == 200:
-            return value
-        raise ValueError('Invalid pagure repo')
+    response = requests.get("https://pagure.io/%s" % value, timeout=5)
+    if response.status_code == 200:
+        return value
+    raise ValueError('Invalid pagure repo')
 
 
-class CommaSeparatedList(Validator):
-    """Fails if the value isn't a comma-separated list.
+def CommaSeparatedList(value):
+    """Fails if the value isn't a list.
 
-    The value will be converted to a Python list. If there is no comma in the
-    original value, it will produce a list with a single element. Whitespaces
-    will be stripped from the elements, so spaces are allowed around the
-    commas.
+    If the value is a string, list, it will be interpreted as a comma-separated
+    list and converted to a Python list. If there is no comma in the original
+    value, it will produce a list with a single element. Whitespaces will be
+    stripped from the elements, so spaces are allowed around the commas.
     """
-
-    @classmethod
-    def from_string(cls, value):
+    if not value:
+        return []
+    if isinstance(value, six.string_types):
         return [
             elem.strip() for elem in value.split(",") if elem.strip()
             ]
-
-    @classmethod
-    def to_string(cls, value):
-        if not value:
-            return ""
-        return ", ".join(value)
+    if not isinstance(value, list):
+        raise ValueError("Expected a list")
+    return value

From 107e4fb9f5bcc363d0552b98328983b87fc223e5 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Sep 28 2017 17:35:08 +0000
Subject: [PATCH 6/20] Sort widgets by index by default


---

diff --git a/hubs/models.py b/hubs/models.py
index eebd178..3b7283d 100644
--- a/hubs/models.py
+++ b/hubs/models.py
@@ -142,7 +142,8 @@ class Hub(ObjectAuthzMixin, BASE):
     __tablename__ = 'hubs'
     name = sa.Column(sa.String(50), primary_key=True)
     created_on = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)
-    widgets = relation('Widget', cascade='all,delete', backref='hub')
+    widgets = relation('Widget', cascade='all,delete', backref='hub',
+                       order_by="Widget.index")
     config = relation('HubConfig', uselist=False, cascade='all,delete',
                       backref='hub')
     archived = sa.Column(sa.Boolean, default=False)

From cee7c15211ac5929154f9b2e40b5fc616bbdc8cc Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Sep 28 2017 17:35:08 +0000
Subject: [PATCH 7/20] Add the hub's right width as a property with a setter


---

diff --git a/hubs/models.py b/hubs/models.py
index 3b7283d..498d4fa 100644
--- a/hubs/models.py
+++ b/hubs/models.py
@@ -337,6 +337,7 @@ class HubConfig(BASE):
         return {
             'summary': self.summary,
             'left_width': self.left_width,
+            'right_width': self.right_width,
             'avatar': self.avatar,
             'chat_channel': self.chat_channel,
             'chat_domain': self.chat_domain,
@@ -347,6 +348,10 @@ class HubConfig(BASE):
     def right_width(self):
         return 12 - self.left_width
 
+    @right_width.setter
+    def right_width(self, value):
+        self.left_width = 12 - value
+
 
 class SpecificDefaultDict(defaultdict):
     """A more specific version of defaultdict.

From 455ab9a811e66ad6ae2cb46a3fa616584da39a29 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Sep 28 2017 17:35:08 +0000
Subject: [PATCH 8/20] Use a REST API for hub and widget config


---

diff --git a/hubs/tests/utils/test_views.py b/hubs/tests/utils/test_views.py
index 262df3d..9c4f54f 100644
--- a/hubs/tests/utils/test_views.py
+++ b/hubs/tests/utils/test_views.py
@@ -5,7 +5,7 @@ import flask
 import hubs.models
 from hubs.app import app
 from hubs.tests import APPTest, FakeAuthorization, auth_set, app_config
-from hubs.utils.views import get_visible_widgets, get_sse_url
+from hubs.utils.views import get_visible_widgets, get_sse_url, move_widget
 
 
 class ViewUtilsTest(APPTest):
@@ -129,3 +129,50 @@ class ViewUtilsTest(APPTest):
                 self.assertEqual(
                     get_sse_url("hub/ralph"),
                     "http://example.com/hub/ralph")
+
+    def test_move_widget(self):
+        hub = hubs.models.Hub(name="testing")
+        self.session.add(hub)
+        widget_names = ["about", "badges", "bugzilla", "contact", "dummy"]
+
+        def get_names():
+            return [
+                w.plugin for w in
+                hubs.models.Widget.query.filter_by(hub=hub).order_by(hubs.models.Widget.index)
+                ]
+
+        def get_widget(name):
+            return hubs.models.Widget.query.filter_by(hub=hub, plugin=name).one()
+
+        def check_move(name, position, expected):
+            move_widget(get_widget(name), position)
+            self.session.flush()
+            self.session.refresh(hub)
+            self.assertListEqual(get_names(), expected)
+
+        for index, name in enumerate(widget_names):
+            widget = hubs.models.Widget(
+                hub=hub, plugin=name,
+                left=True, index=index, _config="{}")
+            self.session.add(widget)
+        self.assertListEqual(get_names(), widget_names)
+        # From middle to middle forwards
+        check_move(
+            "badges", 3,
+            ["about", "bugzilla", "contact", "badges", "dummy"])
+        # From middle to middle backwards
+        check_move(
+            "badges", 1,
+            ["about", "badges", "bugzilla", "contact", "dummy"])
+        # From middle to start
+        check_move(
+            "contact", 0,
+            ["contact", "about", "badges", "bugzilla", "dummy"])
+        # From middle to end
+        check_move(
+            "badges", 5,
+            ["contact", "about", "bugzilla", "dummy", "badges"])
+        # From start to end
+        check_move(
+            "contact", 5,
+            ["about", "bugzilla", "dummy", "badges", "contact"])
diff --git a/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidget.test_delete b/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidget.test_delete
new file mode 100644
index 0000000..1c79340
--- /dev/null
+++ b/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidget.test_delete
@@ -0,0 +1,450 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.12.5]
+    method: GET
+    uri: https://pagure.io/pagure
+  response:
+    body: {string: "<!DOCTYPE html>\n<html lang='en'>\n<head>\n    <meta http-equiv=\"\
+        Content-Type\" content=\"text/html; charset=UTF-8\"/>\n    <title>Overview\
+        \ - pagure - Pagure</title>\n    <link rel=\"shortcut icon\" type=\"image/vnd.microsoft.icon\"\
+        \n        href=\"/static/favicon.ico\"/>\n    <link href=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/pagure.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/vendor/open-iconic/css/open-iconic.min.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/fonts/fonts.css\"\
+        \n        rel=\"stylesheet\" type=\"text/css\" />\n    <link href=\"/static/vendor/hack_fonts/css/hack-extended.min.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <meta name=\"go-import\"\
+        \ content=\"pagure.io/pagure git https://pagure.io/pagure.git\">\n  </head>\n\
+        \  <body id=\"home\">\n  <!-- start masthead -->\n    <div class=\"masthead\"\
+        >\n      <div class=\"container\">\n        <div class=\"row\">\n        \
+        \  <div class=\"col-sm-3\">\n            <a href=\"/\">\n              <img\
+        \ height=40px src=\"/static/pagure-logo.png\"\n                alt=\"pagure\
+        \ Logo\" id=\"pagureLogo\"/>\n            </a>\n          </div>\n       \
+        \   <div class=\"col-sm-9\">\n            <div class=\"row\">\n\n  <nav class=\"\
+        navbar navbar-light p-t-0 p-b-0\">\n      <div class=\"container\">\n    \
+        \    <ul class=\"nav navbar-nav nav-underline pull-xs-right\">\n         \
+        \     <li class=\"nav-item p-l-1\">\n                <a class=\"nav-link btn\
+        \ btn-primary\" href=\"/login/?next=https://pagure.io/pagure\">Log In</a>\n\
+        \              </li>\n        </ul>\n      </div>\n    </nav>\n\n        \
+        \    </div>\n          </div>\n        </div>\n      </div>\n    </div><!--\
+        \ close masthead-->\n\n    <div class=\"bodycontent p-b-3\">\n\n\n<div class=\"\
+        repo-header p-t-1\">\n  <div class=\"container\">\n    <header>\n      <h2\
+        \ class=\"repo-name m-b-0\">\n<a href=\"/pagure\">pagure</a>\n        </h2>\n\
+        \        <div class=\"projectinfo m-t-1 m-b-1\">\nA git centered forge &nbsp;|\
+        \ &nbsp;<a class=\"inline\" href=\"https://pagure.io\">https://pagure.io</a>\
+        \        </div>\n\n    </header>\n    <ul class=\"nav nav-tabs nav-small\"\
+        >\n      <li class=\"nav-item\">\n        <a class=\"nav-link active\" href=\"\
+        /pagure\">\n            <span class=\"oi hidden-md-up\" data-glyph=\"home\"\
+        ></span>\n            <span class=\"hidden-sm-down\">Overview</span>\n   \
+        \     </a>\n      </li>\n        <li class=\"nav-item\" >\n          <a  class=\"\
+        nav-link\" href=\"/docs/pagure/\">\n              <span class=\"oi hidden-md-up\"\
+        \ data-glyph=\"book\"></span>\n              <span class=\"hidden-sm-down\"\
+        >Docs</span>\n          </a>\n        </li>\n\n        <li class=\"nav-item\"\
+        >\n          <a class=\"nav-link\" href=\"/pagure/commits/master\">\n    \
+        \          <span class=\"oi hidden-md-up\" data-glyph=\"spreadsheet\">\n \
+        \             </span><span class=\"hidden-sm-down\">Commits</span>\n     \
+        \       </a>\n        </li>\n\n        <li class=\"nav-item\">\n         \
+        \ <a class=\"nav-link\" href=\"/pagure/tree/master\">\n              <span\
+        \ class=\"oi hidden-md-up\" data-glyph=\"file\"></span>\n              <span\
+        \ class=\"hidden-sm-down\">Files</span>\n          </a>\n        </li>\n\n\
+        \        <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"\
+        /pagure/releases\">\n              <span class=\"oi hidden-md-up\" data-glyph=\"\
+        box\"></span>\n              <span class=\"hidden-sm-down\">Releases</span>\n\
+        \          </a>\n        </li>\n\n        <li class=\"nav-item\">\n      \
+        \      <a class=\"nav-link\" href=\"/pagure/issues\">\n                <span\
+        \ class=\"oi hidden-md-up\" data-glyph=\"warning\"></span>\n             \
+        \   <span class=\"hidden-sm-down\">Issues&nbsp;</span>\n                <span\
+        \ class=\"label label-default label-pill hidden-sm-down\">\n             \
+        \     339\n                </span>\n            </a>\n        </li>\n\n  \
+        \      <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"/pagure/pull-requests\"\
+        >\n              <span class=\"oi hidden-md-up\" data-glyph=\"task\"></span>\n\
+        \              <span class=\"hidden-sm-down\">Pull Requests&nbsp;</span>\n\
+        \              <span class=\"label label-default label-pill hidden-sm-down\"\
+        >\n                16\n              </span>\n          </a>\n        </li>\n\
+        \n        <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"\
+        /pagure/forks\">\n              <span class=\"oi hidden-md-up\" data-glyph=\"\
+        fork\"></span>\n              <span class=\"hidden-sm-down\">Forks&nbsp;</span>\n\
+        \              <span class=\"hidden-sm-down label label-default label-pill\"\
+        >\n                131\n              </span>\n            </a>\n        </li>\n\
+        \n\n    </ul>\n  </div>\n</div>\n\n<div class=\"container p-t-3\">\n  <div\
+        \ class=\"row\">\n        <div class=\"col-md-8\">\n            <section class=\"\
+        readme\">\n              <div class=\"document\">\n<div class=\"section\"\
+        >\n<h1>Pagure</h1>\n<table class=\"docutils field-list\">\n<colgroup><col>\n\
+        <col>\n</colgroup><tbody>\n<tr><th>Author:</th><td>Pierre-Yves Chibon &lt;<a\
+        \ href=\"mailto:pingou@pingoured.fr\">pingou@pingoured.fr</a>&gt;</td>\n</tr>\n\
+        </tbody>\n</table>\n<p>Pagure is a git-centered forge, python based using\
+        \ pygit2.</p>\n<p>With pagure you can host your project with its documentation,\
+        \ let your users\nreport issues or request enhancements using the ticketing\
+        \ system and build your\ncommunity of contributors by allowing them to fork\
+        \ your projects and contribute\nto it via the now-popular pull-request mechanism.</p>\n\
+        <p>Homepage: <a href=\"https://pagure.io/pagure\">https://pagure.io/pagure</a></p>\n\
+        <p>See it at work: <a href=\"https://pagure.io\">https://pagure.io</a></p>\n\
+        <p>Playground version: <a href=\"https://stg.pagure.io\">https://stg.pagure.io</a></p>\n\
+        </div>\n<div class=\"section\">\n<h1>Get it running</h1>\n<p>There are several\
+        \ options when it comes to a development environment. Vagrant\nwill provide\
+        \ you with a virtual machine which you can develop on, or you can\ninstall\
+        \ it directly on your host machine.</p>\n<div class=\"section\">\n<h2>Vagrant</h2>\n\
+        <p>For a more thorough introduction to Vagrant, see\n<a href=\"https://fedoraproject.org/wiki/Vagrant\"\
+        >https://fedoraproject.org/wiki/Vagrant</a>.</p>\n<p>An example Vagrantfile\
+        \ is provided as <code>Vagrantfile.example</code>. To use it,\njust copy it\
+        \ and install Vagrant:</p>\n<pre>$ cp dev/Vagrantfile.example Vagrantfile\n\
+        $ sudo dnf install ansible libvirt vagrant-libvirt vagrant-sshfs vagrant-hostmanager\n\
+        $ vagrant up\n</pre>\n<p>The default <code>Vagrantfile</code> forwards ports\
+        \ from the host to the guest,\nso you can interact with the application as\
+        \ if it were running on your\nhost machine.</p>\n<div class=\"note\">\n<p>Note</p>\n\
+        <p><code><span class=\"pre\">vagrant-hostmanager</span></code> will automatically\
+        \ maintain /etc/hosts for you so you\ncan access the development environment\
+        \ from the host using its hostname, which\nby default is <code><span class=\"\
+        pre\">pagure-dev.example.com</span></code>. You can choose not to use this\n\
+        functionality by simply not installing the <code><span class=\"pre\">vagrant-hostmanager</span></code>\
+        \ plugin, but\nif you want Pagure to provide valid URLs in the UI for git\
+        \ repositories, you\nwill need to adjust Pagure's configuration found in ~/pagure.cfg\
+        \ on the guest.</p>\n</div>\n</div>\n<div class=\"section\">\n<h2>Manually</h2>\n\
+        <ul>\n<li><p>Install the needed system libraries:</p>\n<pre>sudo dnf install\
+        \ git python2-virtualenv libgit2-devel \\\n                 libjpeg-devel\
+        \ gcc libffi-devel redhat-rpm-config\n</pre>\n<div class=\"note\">\n<p>Note</p>\n\
+        <p>Do note the version of libgit2 that you install, for example\nin <code><span\
+        \ class=\"pre\">libgit2-0.23.4-1</span></code> you need to keep in mind the\
+        \ <code>0.23</code></p>\n</div>\n<div class=\"note\">\n<p>Note</p>\n<p>On\
+        \ Fedora 23 and earlier or on RHEL and derivative (CentOS,\nScientific Linux)\
+        \ the package <cite>python2-virtualenv</cite> is named\n<cite>python-virtualenv</cite></p>\n\
+        </div>\n</li>\n<li><p>Retrieve the sources:</p>\n<pre>git clone https://pagure.io/pagure.git\n\
+        cd pagure\n</pre>\n</li>\n<li><p>Install dependencies</p>\n<ul>\n<li><p>create\
+        \ the virtualenv:</p>\n<pre>virtualenv pagure_env\nsource ./pagure_env/bin/activate\n\
+        </pre>\n</li>\n<li><p>Install the correct version of pygit2:</p>\n<pre>pip\
+        \ install pygit2==&lt;version of libgit2 found&gt;.*\n</pre>\n<p>So in our\
+        \ example:</p>\n<pre>pip install pygit2==0.23.*\n</pre>\n</li>\n<li><p>Install\
+        \ the rest of the dependencies:</p>\n<pre>pip install -r requirements.txt\n\
+        </pre>\n</li>\n</ul>\n</li>\n<li><p>Create the folder that will receive the\
+        \ projects, forks, docs, requests and\ntickets' git repo:</p>\n<pre>mkdir\
+        \ -p lcl/{repos,docs,forks,tickets,requests,remotes,attachments,releases}\n\
+        </pre>\n</li>\n<li><p>Create the inital database scheme:</p>\n<pre>python\
+        \ createdb.py\n</pre>\n</li>\n<li><p>Start a worker, in one terminal:</p>\n\
+        <pre>./runworker.py\n</pre>\n</li>\n<li><p>Run the application, in another\
+        \ terminal:</p>\n<pre>./runserver.py\n</pre>\n</li>\n<li><p>To get some profiling\
+        \ information you can also run it as:</p>\n<pre>./runserver.py --profile\n\
+        </pre>\n</li>\n</ul>\n<p>This will launch the application at <a href=\"http://127.0.0.1:5000\"\
+        >http://127.0.0.1:5000</a></p>\n<ul>\n<li><p>To run unit-tests on pagure</p>\n\
+        <ul>\n<li><p>Install the dependencies:</p>\n<pre>pip install -r tests_requirements.txt\n\
+        </pre>\n</li>\n<li><p>Run it:</p>\n<pre>./runtests.sh\n</pre>\n<div class=\"\
+        note\">\n<p>Note</p>\n<p>While testing for worker tasks, pagure uses celery\
+        \ in /usr/bin/\nCelery then looks for eventlet (which we use for testing only)\
+        \ at\nsystem level and not in virtualenv. You will need to install eventlet\n\
+        outside of your virtualenv if you are using one.</p>\n</div>\n</li>\n</ul>\n\
+        </li>\n</ul>\n</div>\n</div>\n</div>\n\n            </section>\n        </div>\n\
+        \      <div class=\"col-md-4\">\n        <div class=\"card\">\n          <div\
+        \ class=\"card-block\">\n            <h5><strong>Contributors</strong></h5>\n\
+        \            <div class=\"m-b-2\">\n              <div>\n                <a\
+        \ href=\"/user/pingou\">\n                  <img class=\"avatar circle\" src=\"\
+        https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                  Pierre-YvesChibon (pingou)\n                </a>\n \
+        \               -  main admin\n              </div>\n                  <div>\n\
+        \                    <a href=\"/user/ryanlerch\">\n                      <img\
+        \ class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/de5bf8d06663adb3bb1b8d49ccab259828fad7dddeb233b073d0c447d79b4c14?s=20&d=retro\"\
+        />\n                      Ryan Lerch (ryanlerch)\n                    </a>\n\
+        \                    - admin\n                  </div>\n                 \
+        \ <div>\n                    <a href=\"/user/puiterwijk\">\n             \
+        \         <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/85f8b5f58b9808429ad69ba3722d64da01ce850635e348e838dfdc053e1140e3?s=20&d=retro\"\
+        />\n                      Patrick &#34;\u30DE\u30EB\u30BF\u30A4\u30F3\u30A2\
+        \u30F3\u30C9\u30EC\u30A2\u30B9&#34; Uiterwijk (puiterwijk)\n             \
+        \       </a>\n                    - commit\n                  </div>\n   \
+        \               <div>\n                    <a href=\"/user/vivekanand1101\"\
+        >\n                      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/342d1c36d5c2f6984933a1238581dfbe74a08bf09ccaf110793703f1e8067169?s=20&d=retro\"\
+        />\n                      Vivek Anand (vivekanand1101)\n                 \
+        \   </a>\n                    - ticket\n                  </div>\n       \
+        \           <div>\n                    <a href=\"/user/farhaan\">\n      \
+        \                <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/b681ddfaffa293b03c57e5f0ed90eb1e06a8f748f4dccdccef8cdd301c66c9ae?s=20&d=retro\"\
+        />\n                      Farhaan Bukhsh (farhaan)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/cverna\">\n                \
+        \      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/c7844b93c43c862adaa29920c09bdfb91efed57a843203e5d0b72673e664c003?s=20&d=retro\"\
+        />\n                      Clement Verna (cverna)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/jcline\">\n                \
+        \      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/1a108f46a960aa35efcabd0b779cd59b8c3aba2927213fe63373921e1aa3fac4?s=20&d=retro\"\
+        />\n                      Jeremy Cline (jcline)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/lslebodn\">\n              \
+        \        <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/8c6fcfe0eec099f1f01f5c237935bb5c97f2063a5c1068e57c5815e0bfecbf70?s=20&d=retro\"\
+        />\n                      Lukas Slebodnik (lslebodn)\n                   \
+        \ </a>\n                    - ticket\n                  </div>\n         \
+        \         <div>\n                    <a href=\"/user/mprahl\">\n         \
+        \             <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/ecae5ad9b37619a38e699de00049e449f120b433c9f26a8beb05b990dfc54c9d?s=20&d=retro\"\
+        />\n                      Matt Prahl (mprahl)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n            </div>\n\
+        \            <h5><strong>Branches</strong></h5>\n            <div class=\"\
+        m-b-2\">\n              <div id=\"branch-master\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"\n          \
+        \          title=\"Default branch\"></span>\n                  <strong title=\"\
+        Currently viewing branch master\" data-toggle=\"tooltip\">master</strong>\n\
+        \                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                  <span class=\"oi text-muted\" data-glyph=\"star\"\n \
+        \                   title=\"Default branch\"></span>\n                </div>\n\
+        \              </div>\n\n              <div id=\"branch-2.9.x\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/2.9.x\"\n           \
+        \         title=\"2.9.x\" data-toggle=\"tooltip\">2.9.x\n                \
+        \  </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-alembic_changes\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/alembic_changes\"\n        \
+        \            title=\"alembic_changes\" data-toggle=\"tooltip\">alembic_changes\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-confirm_merge\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/confirm_merge\"\n   \
+        \                 title=\"confirm_merge\" data-toggle=\"tooltip\">confirm_merge\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-css_tables\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/css_tables\"\n      \
+        \              title=\"css_tables\" data-toggle=\"tooltip\">css_tables\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_del_project\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_del_project\"\n \
+        \                   title=\"fix_del_project\" data-toggle=\"tooltip\">fix_del_project\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_delete\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_delete\"\n      \
+        \              title=\"fix_delete\" data-toggle=\"tooltip\">fix_delete\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_diff_refs\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_diff_refs\"\n   \
+        \                 title=\"fix_diff_refs\" data-toggle=\"tooltip\">fix_diff_refs\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_doc\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_doc\"\n                \
+        \    title=\"fix_doc\" data-toggle=\"tooltip\">fix_doc\n                 \
+        \ </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_linking\" class=\"repoinfo-branchlistitem row\">\n\
+        \                <div class=\"branch_name col-md-8\">\n                  <span\
+        \ class=\"oi text-muted\" data-glyph=\"random\"></span>\n                \
+        \  <a class=\"\" href=\"/pagure/branch/fix_linking\"\n                   \
+        \ title=\"fix_linking\" data-toggle=\"tooltip\">fix_linking\n            \
+        \      </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_pygit_error\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_pygit_error\"\n        \
+        \            title=\"fix_pygit_error\" data-toggle=\"tooltip\">fix_pygit_error\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_reply_to\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_reply_to\"\n    \
+        \                title=\"fix_reply_to\" data-toggle=\"tooltip\">fix_reply_to\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_sse\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_sse\"\n                \
+        \    title=\"fix_sse\" data-toggle=\"tooltip\">fix_sse\n                 \
+        \ </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_sse_status\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_sse_status\"\n         \
+        \           title=\"fix_sse_status\" data-toggle=\"tooltip\">fix_sse_status\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_templates\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_templates\"\n   \
+        \                 title=\"fix_templates\" data-toggle=\"tooltip\">fix_templates\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fixes\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fixes\"\n                  \
+        \  title=\"fixes\" data-toggle=\"tooltip\">fixes\n                  </a>\n\
+        \n                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                </div>\n              </div>\n              <div id=\"\
+        branch-force_flag\" class=\"repoinfo-branchlistitem row\">\n             \
+        \   <div class=\"branch_name col-md-8\">\n                  <span class=\"\
+        oi text-muted\" data-glyph=\"random\"></span>\n                  <a class=\"\
+        \" href=\"/pagure/branch/force_flag\"\n                    title=\"force_flag\"\
+        \ data-toggle=\"tooltip\">force_flag\n                  </a>\n\n         \
+        \       </div>\n                <div class=\"branch_del col-md-4\">\n    \
+        \            </div>\n              </div>\n              <div id=\"branch-gpg_check\"\
+        \ class=\"repoinfo-branchlistitem row\">\n                <div class=\"branch_name\
+        \ col-md-8\">\n                  <span class=\"oi text-muted\" data-glyph=\"\
+        random\"></span>\n                  <a class=\"\" href=\"/pagure/branch/gpg_check\"\
+        \n                    title=\"gpg_check\" data-toggle=\"tooltip\">gpg_check\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-logging_milter\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/logging_milter\"\n  \
+        \                  title=\"logging_milter\" data-toggle=\"tooltip\">logging_milter\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-mirroring\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/mirroring\"\n              \
+        \      title=\"mirroring\" data-toggle=\"tooltip\">mirroring\n           \
+        \       </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-py3_work2\" class=\"repoinfo-branchlistitem row\">\n \
+        \               <div class=\"branch_name col-md-8\">\n                  <span\
+        \ class=\"oi text-muted\" data-glyph=\"random\"></span>\n                \
+        \  <a class=\"\" href=\"/pagure/branch/py3_work2\"\n                    title=\"\
+        py3_work2\" data-toggle=\"tooltip\">py3_work2\n                  </a>\n\n\
+        \                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                </div>\n              </div>\n              <div id=\"\
+        branch-rel_2.2.z\" class=\"repoinfo-branchlistitem row\">\n              \
+        \  <div class=\"branch_name col-md-8\">\n                  <span class=\"\
+        oi text-muted\" data-glyph=\"random\"></span>\n                  <a class=\"\
+        \" href=\"/pagure/branch/rel_2.2.z\"\n                    title=\"rel_2.2.z\"\
+        \ data-toggle=\"tooltip\">rel_2.2.z\n                  </a>\n\n          \
+        \      </div>\n                <div class=\"branch_del col-md-4\">\n     \
+        \           </div>\n              </div>\n              <div id=\"branch-rel_2.3.z\"\
+        \ class=\"repoinfo-branchlistitem row\">\n                <div class=\"branch_name\
+        \ col-md-8\">\n                  <span class=\"oi text-muted\" data-glyph=\"\
+        random\"></span>\n                  <a class=\"\" href=\"/pagure/branch/rel_2.3.z\"\
+        \n                    title=\"rel_2.3.z\" data-toggle=\"tooltip\">rel_2.3.z\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-scroll_metadata\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/scroll_metadata\"\n \
+        \                   title=\"scroll_metadata\" data-toggle=\"tooltip\">scroll_metadata\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-utf_search\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/utf_search\"\n      \
+        \              title=\"utf_search\" data-toggle=\"tooltip\">utf_search\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \         </div>\n            <h5><strong>Source GIT URLs</strong>       \
+        \           <span class=\"pull-xs-right\"><a data-toggle=\"collapse\" href=\"\
+        #moregiturls\"\n                  aria-expanded=\"false\" aria-controls=\"\
+        moregiturls\"\n                  id=\"more_gits\">more</a></span></h5>\n \
+        \           <div>\n              <div class=\"form-group\">\n            \
+        \    <div class=\"input-group input-group-sm\">\n                  <div class=\"\
+        input-group-addon\">GIT</div>\n                  <input class=\"form-control\"\
+        \ type=\"text\" value=\"https://pagure.io/pagure.git\" readonly>\n       \
+        \         </div>\n              </div>\n              <div class=\"collapse\"\
+        \ id=\"moregiturls\">\n                  <h5><strong>Docs GIT URLs</strong></h5>\n\
+        \                  <div class=\"form-group\">\n                    <div class=\"\
+        input-group input-group-sm\">\n                      <div class=\"input-group-addon\"\
+        >GIT</div>\n                      <input class=\"form-control\" type=\"text\"\
+        \ value=\"https://pagure.io/docs/pagure.git\" readonly>\n                \
+        \    </div>\n                  </div>\n              </div>\n            </div>\n\
+        \          </div>\n          <div class=\"repo_date\" title=\"2015-05-13 20:38:10.552662\"\
+        >\n              created 2 years ago\n          </div>\n        </div>\n\n\
+        \          <div class=\"card\">\n            <div class=\"card-header\">\n\
+        \              <strong>Recent Commits in\n                <span class=\"pr-toplabel\"\
+        >\n                  <span class=\"oi\" data-glyph=\"random\"></span>\n  \
+        \                master\n                </span>\n              </strong>\n\
+        \            </div>\n            <div class=\"card-block p-a-0\">\n      \
+        \        <div class=\"list-group list-group-flush\">\n                  <a\
+        \ href=\"/pagure/c/75b9021afbb08a3bdc1128cb282ea9c2394c0b67\" class=\"list-group-item\"\
+        >\n                    <div class=\"commitdate\" title=\"Sep 21 2017 08:20:29\"\
+        >\n                      <small>\n                        <strong>\n     \
+        \                     <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                          Pierre-Yves Chibon\n                       \
+        \ </strong>\n                        commited 5 days ago\n               \
+        \       </small>\n                    </div>\n                    <div><small>\n\
+        \                      Add unit-tests for the fixes around loading issue template\n\
+        \                    </small></div>\n                  </a>\n            \
+        \      <a href=\"/pagure/c/fc1d7f885d8b658ce7ba2fbd9cf20692b32f7732\" class=\"\
+        list-group-item\">\n                    <div class=\"commitdate\" title=\"\
+        Sep 21 2017 08:20:29\">\n                      <small>\n                 \
+        \       <strong>\n                          <img class=\"avatar circle\" src=\"\
+        https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                          Pierre-Yves Chibon\n                       \
+        \ </strong>\n                        commited 5 days ago\n               \
+        \       </small>\n                    </div>\n                    <div><small>\n\
+        \                      Flake8 fix fixing the tests\n                    </small></div>\n\
+        \                  </a>\n                  <a href=\"/pagure/c/91c1f357adc77f0b21e15f6177c68ffc97f3ec67\"\
+        \ class=\"list-group-item\">\n                    <div class=\"commitdate\"\
+        \ title=\"Sep 21 2017 08:20:29\">\n                      <small>\n       \
+        \                 <strong>\n                          <img class=\"avatar\
+        \ circle\" src=\"https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                          Pierre-Yves Chibon\n                       \
+        \ </strong>\n                        commited 5 days ago\n               \
+        \       </small>\n                    </div>\n                    <div><small>\n\
+        \                      Adjust the documentation around ticket templates\n\
+        \                    </small></div>\n                  </a>\n            \
+        \  </div>\n              </div>\n            </div>\n          </div>\n  \
+        \    </div>\n    </div>\n\n\n</div>\n    </div>\n\n    <div class=\"footer\
+        \ p-t-1 p-b-1\">\n        <div class=\"container\">\n            <p class=\"\
+        text-muted credit\">\n         Copyright &copy; 2014-2017 Red Hat\n      \
+        \    <a href=\"https://pagure.io/pagure\">pagure</a> &mdash;\n          3.7.1\
+        \ &mdash; <a href=\"https://docs.pagure.org/pagure/usage/index.html\">Documentation</a>\n\
+        \            </p>\n            <p><a href=\"/ssh_info\">SSH Hostkey/Fingerprint</a></p>\n\
+        \        </div>\n    </div>\n\n    <script type=\"text/javascript\"\n    \
+        \    src=\"/static/vendor/jquery/jquery.min.js\">\n    </script>\n    <script\
+        \ type=\"text/javascript\"\n        src=\"/static/vendor/jquery-ui/jquery-ui.min.js\"\
+        >\n    </script>\n    <script type=\"text/javascript\"\n        src=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.js\"\
+        >\n    </script>\n    <script type=\"text/javascript\">\n$('[data-toggle=\"\
+        tooltip\"]').tooltip({placement : 'bottom'});\n    </script>\n\n<script type=\"\
+        text/javascript\">\n$(document).ready(function() {\n        var currentWatchStatusButton\
+        \ = $('#unwatch_button');\n    currentWatchStatusButton.prepend('<span class=\"\
+        oi\" data-glyph=\"circle-check\" style=\"padding-right:0.5em\"></span>');\n\
+        \    $('.watch-menu a').not(currentWatchStatusButton).css('padding-left',\
+        \ '2.85em');\n\n    $(\".watch-menu a\").click(function(){\n        var selectedValue\
+        \ = $(this).attr('id');\n        var watchProjectForm = $(\"#watch_project\"\
+        );\n        var action = watchProjectForm.attr('action');\n\n        if (selectedValue\
+        \ === \"watch_issues_button\") {\n            action = action.replace('/settings/0',\
+        \ '/settings/1');\n        } else if (selectedValue === \"watch_commits_button\"\
+        ) {\n            action = action.replace('/settings/0', '/settings/2');\n\
+        \        } else if (selectedValue === \"watch_issues_commits_button\") {\n\
+        \            action = action.replace('/settings/0', '/settings/3');\n    \
+        \    } else if (selectedValue === \"reset_button\") {\n            action\
+        \ = action.replace('/settings/0', '/settings/-1');\n        }\n\n        watchProjectForm.attr('action',\
+        \ action);\n        watchProjectForm.submit();\n    });\n});\n</script>\n\n\
+        <script type=\"text/javascript\">\n$(document).ready(function() {\n    $(\"\
+        .extra_gits\").hide();\n});\n\n$(function() {\n  $( \"#more_gits\" ).click(function()\
+        \ {\n      if ($( \"#more_gits\" ).html() == 'more') {\n        $( \"#more_gits\"\
+        \ ).html('less');\n      } else {\n        $( \"#more_gits\" ).html('more');\n\
+        \      }\n  });\n\n});\n</script>\n\n\n</body>\n</html>"}
+    headers:
+      Connection: [Keep-Alive]
+      Content-Length: ['31652']
+      Content-Type: [text/html; charset=utf-8]
+      Date: ['Tue, 26 Sep 2017 15:29:31 GMT']
+      Keep-Alive: ['timeout=5, max=100']
+      Server: [Apache/2.4.6 (Red Hat Enterprise Linux) OpenSSL/1.0.1e-fips mod_wsgi/3.4
+          Python/2.7.5]
+      Set-Cookie: ['pagure=eyJfcGVybWFuZW50Ijp0cnVlLCJjc3JmIjp7IiBiIjoiWkRBMk1qSmpOakUzWkdGaU1qRTRNemd5WkdFNFlUWTVOakUwTkRoak9UWXhOR1V5T0dGa05RPT0ifX0.DKwD3A.-Hon7IeCBYGJMVcECsPCVMDRot4;
+          Expires=Fri, 27-Oct-2017 15:29:32 GMT; Secure; HttpOnly; Path=/']
+      Strict-Transport-Security: [max-age=15768000; includeSubDomains; preload]
+    status: {code: 200, message: OK}
+version: 1
diff --git a/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidget.test_delete_unauthorized b/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidget.test_delete_unauthorized
new file mode 100644
index 0000000..002f0d8
--- /dev/null
+++ b/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidget.test_delete_unauthorized
@@ -0,0 +1,450 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.12.5]
+    method: GET
+    uri: https://pagure.io/pagure
+  response:
+    body: {string: "<!DOCTYPE html>\n<html lang='en'>\n<head>\n    <meta http-equiv=\"\
+        Content-Type\" content=\"text/html; charset=UTF-8\"/>\n    <title>Overview\
+        \ - pagure - Pagure</title>\n    <link rel=\"shortcut icon\" type=\"image/vnd.microsoft.icon\"\
+        \n        href=\"/static/favicon.ico\"/>\n    <link href=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/pagure.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/vendor/open-iconic/css/open-iconic.min.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/fonts/fonts.css\"\
+        \n        rel=\"stylesheet\" type=\"text/css\" />\n    <link href=\"/static/vendor/hack_fonts/css/hack-extended.min.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <meta name=\"go-import\"\
+        \ content=\"pagure.io/pagure git https://pagure.io/pagure.git\">\n  </head>\n\
+        \  <body id=\"home\">\n  <!-- start masthead -->\n    <div class=\"masthead\"\
+        >\n      <div class=\"container\">\n        <div class=\"row\">\n        \
+        \  <div class=\"col-sm-3\">\n            <a href=\"/\">\n              <img\
+        \ height=40px src=\"/static/pagure-logo.png\"\n                alt=\"pagure\
+        \ Logo\" id=\"pagureLogo\"/>\n            </a>\n          </div>\n       \
+        \   <div class=\"col-sm-9\">\n            <div class=\"row\">\n\n  <nav class=\"\
+        navbar navbar-light p-t-0 p-b-0\">\n      <div class=\"container\">\n    \
+        \    <ul class=\"nav navbar-nav nav-underline pull-xs-right\">\n         \
+        \     <li class=\"nav-item p-l-1\">\n                <a class=\"nav-link btn\
+        \ btn-primary\" href=\"/login/?next=https://pagure.io/pagure\">Log In</a>\n\
+        \              </li>\n        </ul>\n      </div>\n    </nav>\n\n        \
+        \    </div>\n          </div>\n        </div>\n      </div>\n    </div><!--\
+        \ close masthead-->\n\n    <div class=\"bodycontent p-b-3\">\n\n\n<div class=\"\
+        repo-header p-t-1\">\n  <div class=\"container\">\n    <header>\n      <h2\
+        \ class=\"repo-name m-b-0\">\n<a href=\"/pagure\">pagure</a>\n        </h2>\n\
+        \        <div class=\"projectinfo m-t-1 m-b-1\">\nA git centered forge &nbsp;|\
+        \ &nbsp;<a class=\"inline\" href=\"https://pagure.io\">https://pagure.io</a>\
+        \        </div>\n\n    </header>\n    <ul class=\"nav nav-tabs nav-small\"\
+        >\n      <li class=\"nav-item\">\n        <a class=\"nav-link active\" href=\"\
+        /pagure\">\n            <span class=\"oi hidden-md-up\" data-glyph=\"home\"\
+        ></span>\n            <span class=\"hidden-sm-down\">Overview</span>\n   \
+        \     </a>\n      </li>\n        <li class=\"nav-item\" >\n          <a  class=\"\
+        nav-link\" href=\"/docs/pagure/\">\n              <span class=\"oi hidden-md-up\"\
+        \ data-glyph=\"book\"></span>\n              <span class=\"hidden-sm-down\"\
+        >Docs</span>\n          </a>\n        </li>\n\n        <li class=\"nav-item\"\
+        >\n          <a class=\"nav-link\" href=\"/pagure/commits/master\">\n    \
+        \          <span class=\"oi hidden-md-up\" data-glyph=\"spreadsheet\">\n \
+        \             </span><span class=\"hidden-sm-down\">Commits</span>\n     \
+        \       </a>\n        </li>\n\n        <li class=\"nav-item\">\n         \
+        \ <a class=\"nav-link\" href=\"/pagure/tree/master\">\n              <span\
+        \ class=\"oi hidden-md-up\" data-glyph=\"file\"></span>\n              <span\
+        \ class=\"hidden-sm-down\">Files</span>\n          </a>\n        </li>\n\n\
+        \        <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"\
+        /pagure/releases\">\n              <span class=\"oi hidden-md-up\" data-glyph=\"\
+        box\"></span>\n              <span class=\"hidden-sm-down\">Releases</span>\n\
+        \          </a>\n        </li>\n\n        <li class=\"nav-item\">\n      \
+        \      <a class=\"nav-link\" href=\"/pagure/issues\">\n                <span\
+        \ class=\"oi hidden-md-up\" data-glyph=\"warning\"></span>\n             \
+        \   <span class=\"hidden-sm-down\">Issues&nbsp;</span>\n                <span\
+        \ class=\"label label-default label-pill hidden-sm-down\">\n             \
+        \     339\n                </span>\n            </a>\n        </li>\n\n  \
+        \      <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"/pagure/pull-requests\"\
+        >\n              <span class=\"oi hidden-md-up\" data-glyph=\"task\"></span>\n\
+        \              <span class=\"hidden-sm-down\">Pull Requests&nbsp;</span>\n\
+        \              <span class=\"label label-default label-pill hidden-sm-down\"\
+        >\n                16\n              </span>\n          </a>\n        </li>\n\
+        \n        <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"\
+        /pagure/forks\">\n              <span class=\"oi hidden-md-up\" data-glyph=\"\
+        fork\"></span>\n              <span class=\"hidden-sm-down\">Forks&nbsp;</span>\n\
+        \              <span class=\"hidden-sm-down label label-default label-pill\"\
+        >\n                131\n              </span>\n            </a>\n        </li>\n\
+        \n\n    </ul>\n  </div>\n</div>\n\n<div class=\"container p-t-3\">\n  <div\
+        \ class=\"row\">\n        <div class=\"col-md-8\">\n            <section class=\"\
+        readme\">\n              <div class=\"document\">\n<div class=\"section\"\
+        >\n<h1>Pagure</h1>\n<table class=\"docutils field-list\">\n<colgroup><col>\n\
+        <col>\n</colgroup><tbody>\n<tr><th>Author:</th><td>Pierre-Yves Chibon &lt;<a\
+        \ href=\"mailto:pingou@pingoured.fr\">pingou@pingoured.fr</a>&gt;</td>\n</tr>\n\
+        </tbody>\n</table>\n<p>Pagure is a git-centered forge, python based using\
+        \ pygit2.</p>\n<p>With pagure you can host your project with its documentation,\
+        \ let your users\nreport issues or request enhancements using the ticketing\
+        \ system and build your\ncommunity of contributors by allowing them to fork\
+        \ your projects and contribute\nto it via the now-popular pull-request mechanism.</p>\n\
+        <p>Homepage: <a href=\"https://pagure.io/pagure\">https://pagure.io/pagure</a></p>\n\
+        <p>See it at work: <a href=\"https://pagure.io\">https://pagure.io</a></p>\n\
+        <p>Playground version: <a href=\"https://stg.pagure.io\">https://stg.pagure.io</a></p>\n\
+        </div>\n<div class=\"section\">\n<h1>Get it running</h1>\n<p>There are several\
+        \ options when it comes to a development environment. Vagrant\nwill provide\
+        \ you with a virtual machine which you can develop on, or you can\ninstall\
+        \ it directly on your host machine.</p>\n<div class=\"section\">\n<h2>Vagrant</h2>\n\
+        <p>For a more thorough introduction to Vagrant, see\n<a href=\"https://fedoraproject.org/wiki/Vagrant\"\
+        >https://fedoraproject.org/wiki/Vagrant</a>.</p>\n<p>An example Vagrantfile\
+        \ is provided as <code>Vagrantfile.example</code>. To use it,\njust copy it\
+        \ and install Vagrant:</p>\n<pre>$ cp dev/Vagrantfile.example Vagrantfile\n\
+        $ sudo dnf install ansible libvirt vagrant-libvirt vagrant-sshfs vagrant-hostmanager\n\
+        $ vagrant up\n</pre>\n<p>The default <code>Vagrantfile</code> forwards ports\
+        \ from the host to the guest,\nso you can interact with the application as\
+        \ if it were running on your\nhost machine.</p>\n<div class=\"note\">\n<p>Note</p>\n\
+        <p><code><span class=\"pre\">vagrant-hostmanager</span></code> will automatically\
+        \ maintain /etc/hosts for you so you\ncan access the development environment\
+        \ from the host using its hostname, which\nby default is <code><span class=\"\
+        pre\">pagure-dev.example.com</span></code>. You can choose not to use this\n\
+        functionality by simply not installing the <code><span class=\"pre\">vagrant-hostmanager</span></code>\
+        \ plugin, but\nif you want Pagure to provide valid URLs in the UI for git\
+        \ repositories, you\nwill need to adjust Pagure's configuration found in ~/pagure.cfg\
+        \ on the guest.</p>\n</div>\n</div>\n<div class=\"section\">\n<h2>Manually</h2>\n\
+        <ul>\n<li><p>Install the needed system libraries:</p>\n<pre>sudo dnf install\
+        \ git python2-virtualenv libgit2-devel \\\n                 libjpeg-devel\
+        \ gcc libffi-devel redhat-rpm-config\n</pre>\n<div class=\"note\">\n<p>Note</p>\n\
+        <p>Do note the version of libgit2 that you install, for example\nin <code><span\
+        \ class=\"pre\">libgit2-0.23.4-1</span></code> you need to keep in mind the\
+        \ <code>0.23</code></p>\n</div>\n<div class=\"note\">\n<p>Note</p>\n<p>On\
+        \ Fedora 23 and earlier or on RHEL and derivative (CentOS,\nScientific Linux)\
+        \ the package <cite>python2-virtualenv</cite> is named\n<cite>python-virtualenv</cite></p>\n\
+        </div>\n</li>\n<li><p>Retrieve the sources:</p>\n<pre>git clone https://pagure.io/pagure.git\n\
+        cd pagure\n</pre>\n</li>\n<li><p>Install dependencies</p>\n<ul>\n<li><p>create\
+        \ the virtualenv:</p>\n<pre>virtualenv pagure_env\nsource ./pagure_env/bin/activate\n\
+        </pre>\n</li>\n<li><p>Install the correct version of pygit2:</p>\n<pre>pip\
+        \ install pygit2==&lt;version of libgit2 found&gt;.*\n</pre>\n<p>So in our\
+        \ example:</p>\n<pre>pip install pygit2==0.23.*\n</pre>\n</li>\n<li><p>Install\
+        \ the rest of the dependencies:</p>\n<pre>pip install -r requirements.txt\n\
+        </pre>\n</li>\n</ul>\n</li>\n<li><p>Create the folder that will receive the\
+        \ projects, forks, docs, requests and\ntickets' git repo:</p>\n<pre>mkdir\
+        \ -p lcl/{repos,docs,forks,tickets,requests,remotes,attachments,releases}\n\
+        </pre>\n</li>\n<li><p>Create the inital database scheme:</p>\n<pre>python\
+        \ createdb.py\n</pre>\n</li>\n<li><p>Start a worker, in one terminal:</p>\n\
+        <pre>./runworker.py\n</pre>\n</li>\n<li><p>Run the application, in another\
+        \ terminal:</p>\n<pre>./runserver.py\n</pre>\n</li>\n<li><p>To get some profiling\
+        \ information you can also run it as:</p>\n<pre>./runserver.py --profile\n\
+        </pre>\n</li>\n</ul>\n<p>This will launch the application at <a href=\"http://127.0.0.1:5000\"\
+        >http://127.0.0.1:5000</a></p>\n<ul>\n<li><p>To run unit-tests on pagure</p>\n\
+        <ul>\n<li><p>Install the dependencies:</p>\n<pre>pip install -r tests_requirements.txt\n\
+        </pre>\n</li>\n<li><p>Run it:</p>\n<pre>./runtests.sh\n</pre>\n<div class=\"\
+        note\">\n<p>Note</p>\n<p>While testing for worker tasks, pagure uses celery\
+        \ in /usr/bin/\nCelery then looks for eventlet (which we use for testing only)\
+        \ at\nsystem level and not in virtualenv. You will need to install eventlet\n\
+        outside of your virtualenv if you are using one.</p>\n</div>\n</li>\n</ul>\n\
+        </li>\n</ul>\n</div>\n</div>\n</div>\n\n            </section>\n        </div>\n\
+        \      <div class=\"col-md-4\">\n        <div class=\"card\">\n          <div\
+        \ class=\"card-block\">\n            <h5><strong>Contributors</strong></h5>\n\
+        \            <div class=\"m-b-2\">\n              <div>\n                <a\
+        \ href=\"/user/pingou\">\n                  <img class=\"avatar circle\" src=\"\
+        https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                  Pierre-YvesChibon (pingou)\n                </a>\n \
+        \               -  main admin\n              </div>\n                  <div>\n\
+        \                    <a href=\"/user/ryanlerch\">\n                      <img\
+        \ class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/de5bf8d06663adb3bb1b8d49ccab259828fad7dddeb233b073d0c447d79b4c14?s=20&d=retro\"\
+        />\n                      Ryan Lerch (ryanlerch)\n                    </a>\n\
+        \                    - admin\n                  </div>\n                 \
+        \ <div>\n                    <a href=\"/user/puiterwijk\">\n             \
+        \         <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/85f8b5f58b9808429ad69ba3722d64da01ce850635e348e838dfdc053e1140e3?s=20&d=retro\"\
+        />\n                      Patrick &#34;\u30DE\u30EB\u30BF\u30A4\u30F3\u30A2\
+        \u30F3\u30C9\u30EC\u30A2\u30B9&#34; Uiterwijk (puiterwijk)\n             \
+        \       </a>\n                    - commit\n                  </div>\n   \
+        \               <div>\n                    <a href=\"/user/vivekanand1101\"\
+        >\n                      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/342d1c36d5c2f6984933a1238581dfbe74a08bf09ccaf110793703f1e8067169?s=20&d=retro\"\
+        />\n                      Vivek Anand (vivekanand1101)\n                 \
+        \   </a>\n                    - ticket\n                  </div>\n       \
+        \           <div>\n                    <a href=\"/user/farhaan\">\n      \
+        \                <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/b681ddfaffa293b03c57e5f0ed90eb1e06a8f748f4dccdccef8cdd301c66c9ae?s=20&d=retro\"\
+        />\n                      Farhaan Bukhsh (farhaan)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/cverna\">\n                \
+        \      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/c7844b93c43c862adaa29920c09bdfb91efed57a843203e5d0b72673e664c003?s=20&d=retro\"\
+        />\n                      Clement Verna (cverna)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/jcline\">\n                \
+        \      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/1a108f46a960aa35efcabd0b779cd59b8c3aba2927213fe63373921e1aa3fac4?s=20&d=retro\"\
+        />\n                      Jeremy Cline (jcline)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/lslebodn\">\n              \
+        \        <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/8c6fcfe0eec099f1f01f5c237935bb5c97f2063a5c1068e57c5815e0bfecbf70?s=20&d=retro\"\
+        />\n                      Lukas Slebodnik (lslebodn)\n                   \
+        \ </a>\n                    - ticket\n                  </div>\n         \
+        \         <div>\n                    <a href=\"/user/mprahl\">\n         \
+        \             <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/ecae5ad9b37619a38e699de00049e449f120b433c9f26a8beb05b990dfc54c9d?s=20&d=retro\"\
+        />\n                      Matt Prahl (mprahl)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n            </div>\n\
+        \            <h5><strong>Branches</strong></h5>\n            <div class=\"\
+        m-b-2\">\n              <div id=\"branch-master\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"\n          \
+        \          title=\"Default branch\"></span>\n                  <strong title=\"\
+        Currently viewing branch master\" data-toggle=\"tooltip\">master</strong>\n\
+        \                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                  <span class=\"oi text-muted\" data-glyph=\"star\"\n \
+        \                   title=\"Default branch\"></span>\n                </div>\n\
+        \              </div>\n\n              <div id=\"branch-2.9.x\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/2.9.x\"\n           \
+        \         title=\"2.9.x\" data-toggle=\"tooltip\">2.9.x\n                \
+        \  </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-alembic_changes\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/alembic_changes\"\n        \
+        \            title=\"alembic_changes\" data-toggle=\"tooltip\">alembic_changes\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-confirm_merge\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/confirm_merge\"\n   \
+        \                 title=\"confirm_merge\" data-toggle=\"tooltip\">confirm_merge\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-css_tables\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/css_tables\"\n      \
+        \              title=\"css_tables\" data-toggle=\"tooltip\">css_tables\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_del_project\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_del_project\"\n \
+        \                   title=\"fix_del_project\" data-toggle=\"tooltip\">fix_del_project\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_delete\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_delete\"\n      \
+        \              title=\"fix_delete\" data-toggle=\"tooltip\">fix_delete\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_diff_refs\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_diff_refs\"\n   \
+        \                 title=\"fix_diff_refs\" data-toggle=\"tooltip\">fix_diff_refs\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_doc\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_doc\"\n                \
+        \    title=\"fix_doc\" data-toggle=\"tooltip\">fix_doc\n                 \
+        \ </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_linking\" class=\"repoinfo-branchlistitem row\">\n\
+        \                <div class=\"branch_name col-md-8\">\n                  <span\
+        \ class=\"oi text-muted\" data-glyph=\"random\"></span>\n                \
+        \  <a class=\"\" href=\"/pagure/branch/fix_linking\"\n                   \
+        \ title=\"fix_linking\" data-toggle=\"tooltip\">fix_linking\n            \
+        \      </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_pygit_error\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_pygit_error\"\n        \
+        \            title=\"fix_pygit_error\" data-toggle=\"tooltip\">fix_pygit_error\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_reply_to\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_reply_to\"\n    \
+        \                title=\"fix_reply_to\" data-toggle=\"tooltip\">fix_reply_to\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_sse\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_sse\"\n                \
+        \    title=\"fix_sse\" data-toggle=\"tooltip\">fix_sse\n                 \
+        \ </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_sse_status\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_sse_status\"\n         \
+        \           title=\"fix_sse_status\" data-toggle=\"tooltip\">fix_sse_status\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_templates\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_templates\"\n   \
+        \                 title=\"fix_templates\" data-toggle=\"tooltip\">fix_templates\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fixes\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fixes\"\n                  \
+        \  title=\"fixes\" data-toggle=\"tooltip\">fixes\n                  </a>\n\
+        \n                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                </div>\n              </div>\n              <div id=\"\
+        branch-force_flag\" class=\"repoinfo-branchlistitem row\">\n             \
+        \   <div class=\"branch_name col-md-8\">\n                  <span class=\"\
+        oi text-muted\" data-glyph=\"random\"></span>\n                  <a class=\"\
+        \" href=\"/pagure/branch/force_flag\"\n                    title=\"force_flag\"\
+        \ data-toggle=\"tooltip\">force_flag\n                  </a>\n\n         \
+        \       </div>\n                <div class=\"branch_del col-md-4\">\n    \
+        \            </div>\n              </div>\n              <div id=\"branch-gpg_check\"\
+        \ class=\"repoinfo-branchlistitem row\">\n                <div class=\"branch_name\
+        \ col-md-8\">\n                  <span class=\"oi text-muted\" data-glyph=\"\
+        random\"></span>\n                  <a class=\"\" href=\"/pagure/branch/gpg_check\"\
+        \n                    title=\"gpg_check\" data-toggle=\"tooltip\">gpg_check\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-logging_milter\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/logging_milter\"\n  \
+        \                  title=\"logging_milter\" data-toggle=\"tooltip\">logging_milter\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-mirroring\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/mirroring\"\n              \
+        \      title=\"mirroring\" data-toggle=\"tooltip\">mirroring\n           \
+        \       </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-py3_work2\" class=\"repoinfo-branchlistitem row\">\n \
+        \               <div class=\"branch_name col-md-8\">\n                  <span\
+        \ class=\"oi text-muted\" data-glyph=\"random\"></span>\n                \
+        \  <a class=\"\" href=\"/pagure/branch/py3_work2\"\n                    title=\"\
+        py3_work2\" data-toggle=\"tooltip\">py3_work2\n                  </a>\n\n\
+        \                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                </div>\n              </div>\n              <div id=\"\
+        branch-rel_2.2.z\" class=\"repoinfo-branchlistitem row\">\n              \
+        \  <div class=\"branch_name col-md-8\">\n                  <span class=\"\
+        oi text-muted\" data-glyph=\"random\"></span>\n                  <a class=\"\
+        \" href=\"/pagure/branch/rel_2.2.z\"\n                    title=\"rel_2.2.z\"\
+        \ data-toggle=\"tooltip\">rel_2.2.z\n                  </a>\n\n          \
+        \      </div>\n                <div class=\"branch_del col-md-4\">\n     \
+        \           </div>\n              </div>\n              <div id=\"branch-rel_2.3.z\"\
+        \ class=\"repoinfo-branchlistitem row\">\n                <div class=\"branch_name\
+        \ col-md-8\">\n                  <span class=\"oi text-muted\" data-glyph=\"\
+        random\"></span>\n                  <a class=\"\" href=\"/pagure/branch/rel_2.3.z\"\
+        \n                    title=\"rel_2.3.z\" data-toggle=\"tooltip\">rel_2.3.z\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-scroll_metadata\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/scroll_metadata\"\n \
+        \                   title=\"scroll_metadata\" data-toggle=\"tooltip\">scroll_metadata\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-utf_search\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/utf_search\"\n      \
+        \              title=\"utf_search\" data-toggle=\"tooltip\">utf_search\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \         </div>\n            <h5><strong>Source GIT URLs</strong>       \
+        \           <span class=\"pull-xs-right\"><a data-toggle=\"collapse\" href=\"\
+        #moregiturls\"\n                  aria-expanded=\"false\" aria-controls=\"\
+        moregiturls\"\n                  id=\"more_gits\">more</a></span></h5>\n \
+        \           <div>\n              <div class=\"form-group\">\n            \
+        \    <div class=\"input-group input-group-sm\">\n                  <div class=\"\
+        input-group-addon\">GIT</div>\n                  <input class=\"form-control\"\
+        \ type=\"text\" value=\"https://pagure.io/pagure.git\" readonly>\n       \
+        \         </div>\n              </div>\n              <div class=\"collapse\"\
+        \ id=\"moregiturls\">\n                  <h5><strong>Docs GIT URLs</strong></h5>\n\
+        \                  <div class=\"form-group\">\n                    <div class=\"\
+        input-group input-group-sm\">\n                      <div class=\"input-group-addon\"\
+        >GIT</div>\n                      <input class=\"form-control\" type=\"text\"\
+        \ value=\"https://pagure.io/docs/pagure.git\" readonly>\n                \
+        \    </div>\n                  </div>\n              </div>\n            </div>\n\
+        \          </div>\n          <div class=\"repo_date\" title=\"2015-05-13 20:38:10.552662\"\
+        >\n              created 2 years ago\n          </div>\n        </div>\n\n\
+        \          <div class=\"card\">\n            <div class=\"card-header\">\n\
+        \              <strong>Recent Commits in\n                <span class=\"pr-toplabel\"\
+        >\n                  <span class=\"oi\" data-glyph=\"random\"></span>\n  \
+        \                master\n                </span>\n              </strong>\n\
+        \            </div>\n            <div class=\"card-block p-a-0\">\n      \
+        \        <div class=\"list-group list-group-flush\">\n                  <a\
+        \ href=\"/pagure/c/75b9021afbb08a3bdc1128cb282ea9c2394c0b67\" class=\"list-group-item\"\
+        >\n                    <div class=\"commitdate\" title=\"Sep 21 2017 08:20:29\"\
+        >\n                      <small>\n                        <strong>\n     \
+        \                     <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                          Pierre-Yves Chibon\n                       \
+        \ </strong>\n                        commited 5 days ago\n               \
+        \       </small>\n                    </div>\n                    <div><small>\n\
+        \                      Add unit-tests for the fixes around loading issue template\n\
+        \                    </small></div>\n                  </a>\n            \
+        \      <a href=\"/pagure/c/fc1d7f885d8b658ce7ba2fbd9cf20692b32f7732\" class=\"\
+        list-group-item\">\n                    <div class=\"commitdate\" title=\"\
+        Sep 21 2017 08:20:29\">\n                      <small>\n                 \
+        \       <strong>\n                          <img class=\"avatar circle\" src=\"\
+        https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                          Pierre-Yves Chibon\n                       \
+        \ </strong>\n                        commited 5 days ago\n               \
+        \       </small>\n                    </div>\n                    <div><small>\n\
+        \                      Flake8 fix fixing the tests\n                    </small></div>\n\
+        \                  </a>\n                  <a href=\"/pagure/c/91c1f357adc77f0b21e15f6177c68ffc97f3ec67\"\
+        \ class=\"list-group-item\">\n                    <div class=\"commitdate\"\
+        \ title=\"Sep 21 2017 08:20:29\">\n                      <small>\n       \
+        \                 <strong>\n                          <img class=\"avatar\
+        \ circle\" src=\"https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                          Pierre-Yves Chibon\n                       \
+        \ </strong>\n                        commited 5 days ago\n               \
+        \       </small>\n                    </div>\n                    <div><small>\n\
+        \                      Adjust the documentation around ticket templates\n\
+        \                    </small></div>\n                  </a>\n            \
+        \  </div>\n              </div>\n            </div>\n          </div>\n  \
+        \    </div>\n    </div>\n\n\n</div>\n    </div>\n\n    <div class=\"footer\
+        \ p-t-1 p-b-1\">\n        <div class=\"container\">\n            <p class=\"\
+        text-muted credit\">\n         Copyright &copy; 2014-2017 Red Hat\n      \
+        \    <a href=\"https://pagure.io/pagure\">pagure</a> &mdash;\n          3.7.1\
+        \ &mdash; <a href=\"https://docs.pagure.org/pagure/usage/index.html\">Documentation</a>\n\
+        \            </p>\n            <p><a href=\"/ssh_info\">SSH Hostkey/Fingerprint</a></p>\n\
+        \        </div>\n    </div>\n\n    <script type=\"text/javascript\"\n    \
+        \    src=\"/static/vendor/jquery/jquery.min.js\">\n    </script>\n    <script\
+        \ type=\"text/javascript\"\n        src=\"/static/vendor/jquery-ui/jquery-ui.min.js\"\
+        >\n    </script>\n    <script type=\"text/javascript\"\n        src=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.js\"\
+        >\n    </script>\n    <script type=\"text/javascript\">\n$('[data-toggle=\"\
+        tooltip\"]').tooltip({placement : 'bottom'});\n    </script>\n\n<script type=\"\
+        text/javascript\">\n$(document).ready(function() {\n        var currentWatchStatusButton\
+        \ = $('#unwatch_button');\n    currentWatchStatusButton.prepend('<span class=\"\
+        oi\" data-glyph=\"circle-check\" style=\"padding-right:0.5em\"></span>');\n\
+        \    $('.watch-menu a').not(currentWatchStatusButton).css('padding-left',\
+        \ '2.85em');\n\n    $(\".watch-menu a\").click(function(){\n        var selectedValue\
+        \ = $(this).attr('id');\n        var watchProjectForm = $(\"#watch_project\"\
+        );\n        var action = watchProjectForm.attr('action');\n\n        if (selectedValue\
+        \ === \"watch_issues_button\") {\n            action = action.replace('/settings/0',\
+        \ '/settings/1');\n        } else if (selectedValue === \"watch_commits_button\"\
+        ) {\n            action = action.replace('/settings/0', '/settings/2');\n\
+        \        } else if (selectedValue === \"watch_issues_commits_button\") {\n\
+        \            action = action.replace('/settings/0', '/settings/3');\n    \
+        \    } else if (selectedValue === \"reset_button\") {\n            action\
+        \ = action.replace('/settings/0', '/settings/-1');\n        }\n\n        watchProjectForm.attr('action',\
+        \ action);\n        watchProjectForm.submit();\n    });\n});\n</script>\n\n\
+        <script type=\"text/javascript\">\n$(document).ready(function() {\n    $(\"\
+        .extra_gits\").hide();\n});\n\n$(function() {\n  $( \"#more_gits\" ).click(function()\
+        \ {\n      if ($( \"#more_gits\" ).html() == 'more') {\n        $( \"#more_gits\"\
+        \ ).html('less');\n      } else {\n        $( \"#more_gits\" ).html('more');\n\
+        \      }\n  });\n\n});\n</script>\n\n\n</body>\n</html>"}
+    headers:
+      Connection: [Keep-Alive]
+      Content-Length: ['31652']
+      Content-Type: [text/html; charset=utf-8]
+      Date: ['Tue, 26 Sep 2017 15:29:34 GMT']
+      Keep-Alive: ['timeout=5, max=100']
+      Server: [Apache/2.4.6 (Red Hat Enterprise Linux) OpenSSL/1.0.1e-fips mod_wsgi/3.4
+          Python/2.7.5]
+      Set-Cookie: ['pagure=eyJfcGVybWFuZW50Ijp0cnVlLCJjc3JmIjp7IiBiIjoiTnpZelpqTmxZVGhoT1RJMk16aGlabVV4WkRnellUVXdPR00xWmpKbU9EVmtORGcyWVRZeE9BPT0ifX0.DKwD3g.EYEwG8LIbAU2xuYJw0UJyjisahM;
+          Expires=Fri, 27-Oct-2017 15:29:34 GMT; Secure; HttpOnly; Path=/']
+      Strict-Transport-Security: [max-age=15768000; includeSubDomains; preload]
+    status: {code: 200, message: OK}
+version: 1
diff --git a/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidgets.test_get_removed_widget b/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidgets.test_get_removed_widget
new file mode 100644
index 0000000..3cb4651
--- /dev/null
+++ b/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidgets.test_get_removed_widget
@@ -0,0 +1,450 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.12.5]
+    method: GET
+    uri: https://pagure.io/pagure
+  response:
+    body: {string: "<!DOCTYPE html>\n<html lang='en'>\n<head>\n    <meta http-equiv=\"\
+        Content-Type\" content=\"text/html; charset=UTF-8\"/>\n    <title>Overview\
+        \ - pagure - Pagure</title>\n    <link rel=\"shortcut icon\" type=\"image/vnd.microsoft.icon\"\
+        \n        href=\"/static/favicon.ico\"/>\n    <link href=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/pagure.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/vendor/open-iconic/css/open-iconic.min.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/fonts/fonts.css\"\
+        \n        rel=\"stylesheet\" type=\"text/css\" />\n    <link href=\"/static/vendor/hack_fonts/css/hack-extended.min.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <meta name=\"go-import\"\
+        \ content=\"pagure.io/pagure git https://pagure.io/pagure.git\">\n  </head>\n\
+        \  <body id=\"home\">\n  <!-- start masthead -->\n    <div class=\"masthead\"\
+        >\n      <div class=\"container\">\n        <div class=\"row\">\n        \
+        \  <div class=\"col-sm-3\">\n            <a href=\"/\">\n              <img\
+        \ height=40px src=\"/static/pagure-logo.png\"\n                alt=\"pagure\
+        \ Logo\" id=\"pagureLogo\"/>\n            </a>\n          </div>\n       \
+        \   <div class=\"col-sm-9\">\n            <div class=\"row\">\n\n  <nav class=\"\
+        navbar navbar-light p-t-0 p-b-0\">\n      <div class=\"container\">\n    \
+        \    <ul class=\"nav navbar-nav nav-underline pull-xs-right\">\n         \
+        \     <li class=\"nav-item p-l-1\">\n                <a class=\"nav-link btn\
+        \ btn-primary\" href=\"/login/?next=https://pagure.io/pagure\">Log In</a>\n\
+        \              </li>\n        </ul>\n      </div>\n    </nav>\n\n        \
+        \    </div>\n          </div>\n        </div>\n      </div>\n    </div><!--\
+        \ close masthead-->\n\n    <div class=\"bodycontent p-b-3\">\n\n\n<div class=\"\
+        repo-header p-t-1\">\n  <div class=\"container\">\n    <header>\n      <h2\
+        \ class=\"repo-name m-b-0\">\n<a href=\"/pagure\">pagure</a>\n        </h2>\n\
+        \        <div class=\"projectinfo m-t-1 m-b-1\">\nA git centered forge &nbsp;|\
+        \ &nbsp;<a class=\"inline\" href=\"https://pagure.io\">https://pagure.io</a>\
+        \        </div>\n\n    </header>\n    <ul class=\"nav nav-tabs nav-small\"\
+        >\n      <li class=\"nav-item\">\n        <a class=\"nav-link active\" href=\"\
+        /pagure\">\n            <span class=\"oi hidden-md-up\" data-glyph=\"home\"\
+        ></span>\n            <span class=\"hidden-sm-down\">Overview</span>\n   \
+        \     </a>\n      </li>\n        <li class=\"nav-item\" >\n          <a  class=\"\
+        nav-link\" href=\"/docs/pagure/\">\n              <span class=\"oi hidden-md-up\"\
+        \ data-glyph=\"book\"></span>\n              <span class=\"hidden-sm-down\"\
+        >Docs</span>\n          </a>\n        </li>\n\n        <li class=\"nav-item\"\
+        >\n          <a class=\"nav-link\" href=\"/pagure/commits/master\">\n    \
+        \          <span class=\"oi hidden-md-up\" data-glyph=\"spreadsheet\">\n \
+        \             </span><span class=\"hidden-sm-down\">Commits</span>\n     \
+        \       </a>\n        </li>\n\n        <li class=\"nav-item\">\n         \
+        \ <a class=\"nav-link\" href=\"/pagure/tree/master\">\n              <span\
+        \ class=\"oi hidden-md-up\" data-glyph=\"file\"></span>\n              <span\
+        \ class=\"hidden-sm-down\">Files</span>\n          </a>\n        </li>\n\n\
+        \        <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"\
+        /pagure/releases\">\n              <span class=\"oi hidden-md-up\" data-glyph=\"\
+        box\"></span>\n              <span class=\"hidden-sm-down\">Releases</span>\n\
+        \          </a>\n        </li>\n\n        <li class=\"nav-item\">\n      \
+        \      <a class=\"nav-link\" href=\"/pagure/issues\">\n                <span\
+        \ class=\"oi hidden-md-up\" data-glyph=\"warning\"></span>\n             \
+        \   <span class=\"hidden-sm-down\">Issues&nbsp;</span>\n                <span\
+        \ class=\"label label-default label-pill hidden-sm-down\">\n             \
+        \     340\n                </span>\n            </a>\n        </li>\n\n  \
+        \      <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"/pagure/pull-requests\"\
+        >\n              <span class=\"oi hidden-md-up\" data-glyph=\"task\"></span>\n\
+        \              <span class=\"hidden-sm-down\">Pull Requests&nbsp;</span>\n\
+        \              <span class=\"label label-default label-pill hidden-sm-down\"\
+        >\n                15\n              </span>\n          </a>\n        </li>\n\
+        \n        <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"\
+        /pagure/forks\">\n              <span class=\"oi hidden-md-up\" data-glyph=\"\
+        fork\"></span>\n              <span class=\"hidden-sm-down\">Forks&nbsp;</span>\n\
+        \              <span class=\"hidden-sm-down label label-default label-pill\"\
+        >\n                131\n              </span>\n            </a>\n        </li>\n\
+        \n\n    </ul>\n  </div>\n</div>\n\n<div class=\"container p-t-3\">\n  <div\
+        \ class=\"row\">\n        <div class=\"col-md-8\">\n            <section class=\"\
+        readme\">\n              <div class=\"document\">\n<div class=\"section\"\
+        >\n<h1>Pagure</h1>\n<table class=\"docutils field-list\">\n<colgroup><col>\n\
+        <col>\n</colgroup><tbody>\n<tr><th>Author:</th><td>Pierre-Yves Chibon &lt;<a\
+        \ href=\"mailto:pingou@pingoured.fr\">pingou@pingoured.fr</a>&gt;</td>\n</tr>\n\
+        </tbody>\n</table>\n<p>Pagure is a git-centered forge, python based using\
+        \ pygit2.</p>\n<p>With pagure you can host your project with its documentation,\
+        \ let your users\nreport issues or request enhancements using the ticketing\
+        \ system and build your\ncommunity of contributors by allowing them to fork\
+        \ your projects and contribute\nto it via the now-popular pull-request mechanism.</p>\n\
+        <p>Homepage: <a href=\"https://pagure.io/pagure\">https://pagure.io/pagure</a></p>\n\
+        <p>See it at work: <a href=\"https://pagure.io\">https://pagure.io</a></p>\n\
+        <p>Playground version: <a href=\"https://stg.pagure.io\">https://stg.pagure.io</a></p>\n\
+        </div>\n<div class=\"section\">\n<h1>Get it running</h1>\n<p>There are several\
+        \ options when it comes to a development environment. Vagrant\nwill provide\
+        \ you with a virtual machine which you can develop on, or you can\ninstall\
+        \ it directly on your host machine.</p>\n<div class=\"section\">\n<h2>Vagrant</h2>\n\
+        <p>For a more thorough introduction to Vagrant, see\n<a href=\"https://fedoraproject.org/wiki/Vagrant\"\
+        >https://fedoraproject.org/wiki/Vagrant</a>.</p>\n<p>An example Vagrantfile\
+        \ is provided as <code>Vagrantfile.example</code>. To use it,\njust copy it\
+        \ and install Vagrant:</p>\n<pre>$ cp dev/Vagrantfile.example Vagrantfile\n\
+        $ sudo dnf install ansible libvirt vagrant-libvirt vagrant-sshfs vagrant-hostmanager\n\
+        $ vagrant up\n</pre>\n<p>The default <code>Vagrantfile</code> forwards ports\
+        \ from the host to the guest,\nso you can interact with the application as\
+        \ if it were running on your\nhost machine.</p>\n<div class=\"note\">\n<p>Note</p>\n\
+        <p><code><span class=\"pre\">vagrant-hostmanager</span></code> will automatically\
+        \ maintain /etc/hosts for you so you\ncan access the development environment\
+        \ from the host using its hostname, which\nby default is <code><span class=\"\
+        pre\">pagure-dev.example.com</span></code>. You can choose not to use this\n\
+        functionality by simply not installing the <code><span class=\"pre\">vagrant-hostmanager</span></code>\
+        \ plugin, but\nif you want Pagure to provide valid URLs in the UI for git\
+        \ repositories, you\nwill need to adjust Pagure's configuration found in ~/pagure.cfg\
+        \ on the guest.</p>\n</div>\n</div>\n<div class=\"section\">\n<h2>Manually</h2>\n\
+        <ul>\n<li><p>Install the needed system libraries:</p>\n<pre>sudo dnf install\
+        \ git python2-virtualenv libgit2-devel \\\n                 libjpeg-devel\
+        \ gcc libffi-devel redhat-rpm-config\n</pre>\n<div class=\"note\">\n<p>Note</p>\n\
+        <p>Do note the version of libgit2 that you install, for example\nin <code><span\
+        \ class=\"pre\">libgit2-0.23.4-1</span></code> you need to keep in mind the\
+        \ <code>0.23</code></p>\n</div>\n<div class=\"note\">\n<p>Note</p>\n<p>On\
+        \ Fedora 23 and earlier or on RHEL and derivative (CentOS,\nScientific Linux)\
+        \ the package <cite>python2-virtualenv</cite> is named\n<cite>python-virtualenv</cite></p>\n\
+        </div>\n</li>\n<li><p>Retrieve the sources:</p>\n<pre>git clone https://pagure.io/pagure.git\n\
+        cd pagure\n</pre>\n</li>\n<li><p>Install dependencies</p>\n<ul>\n<li><p>create\
+        \ the virtualenv:</p>\n<pre>virtualenv pagure_env\nsource ./pagure_env/bin/activate\n\
+        </pre>\n</li>\n<li><p>Install the correct version of pygit2:</p>\n<pre>pip\
+        \ install pygit2==&lt;version of libgit2 found&gt;.*\n</pre>\n<p>So in our\
+        \ example:</p>\n<pre>pip install pygit2==0.23.*\n</pre>\n</li>\n<li><p>Install\
+        \ the rest of the dependencies:</p>\n<pre>pip install -r requirements.txt\n\
+        </pre>\n</li>\n</ul>\n</li>\n<li><p>Create the folder that will receive the\
+        \ projects, forks, docs, requests and\ntickets' git repo:</p>\n<pre>mkdir\
+        \ -p lcl/{repos,docs,forks,tickets,requests,remotes,attachments,releases}\n\
+        </pre>\n</li>\n<li><p>Create the inital database scheme:</p>\n<pre>python\
+        \ createdb.py\n</pre>\n</li>\n<li><p>Start a worker, in one terminal:</p>\n\
+        <pre>./runworker.py\n</pre>\n</li>\n<li><p>Run the application, in another\
+        \ terminal:</p>\n<pre>./runserver.py\n</pre>\n</li>\n<li><p>To get some profiling\
+        \ information you can also run it as:</p>\n<pre>./runserver.py --profile\n\
+        </pre>\n</li>\n</ul>\n<p>This will launch the application at <a href=\"http://127.0.0.1:5000\"\
+        >http://127.0.0.1:5000</a></p>\n<ul>\n<li><p>To run unit-tests on pagure</p>\n\
+        <ul>\n<li><p>Install the dependencies:</p>\n<pre>pip install -r tests_requirements.txt\n\
+        </pre>\n</li>\n<li><p>Run it:</p>\n<pre>./runtests.sh\n</pre>\n<div class=\"\
+        note\">\n<p>Note</p>\n<p>While testing for worker tasks, pagure uses celery\
+        \ in /usr/bin/\nCelery then looks for eventlet (which we use for testing only)\
+        \ at\nsystem level and not in virtualenv. You will need to install eventlet\n\
+        outside of your virtualenv if you are using one.</p>\n</div>\n</li>\n</ul>\n\
+        </li>\n</ul>\n</div>\n</div>\n</div>\n\n            </section>\n        </div>\n\
+        \      <div class=\"col-md-4\">\n        <div class=\"card\">\n          <div\
+        \ class=\"card-block\">\n            <h5><strong>Contributors</strong></h5>\n\
+        \            <div class=\"m-b-2\">\n              <div>\n                <a\
+        \ href=\"/user/pingou\">\n                  <img class=\"avatar circle\" src=\"\
+        https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                  Pierre-YvesChibon (pingou)\n                </a>\n \
+        \               -  main admin\n              </div>\n                  <div>\n\
+        \                    <a href=\"/user/ryanlerch\">\n                      <img\
+        \ class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/de5bf8d06663adb3bb1b8d49ccab259828fad7dddeb233b073d0c447d79b4c14?s=20&d=retro\"\
+        />\n                      Ryan Lerch (ryanlerch)\n                    </a>\n\
+        \                    - admin\n                  </div>\n                 \
+        \ <div>\n                    <a href=\"/user/puiterwijk\">\n             \
+        \         <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/85f8b5f58b9808429ad69ba3722d64da01ce850635e348e838dfdc053e1140e3?s=20&d=retro\"\
+        />\n                      Patrick &#34;\u30DE\u30EB\u30BF\u30A4\u30F3\u30A2\
+        \u30F3\u30C9\u30EC\u30A2\u30B9&#34; Uiterwijk (puiterwijk)\n             \
+        \       </a>\n                    - commit\n                  </div>\n   \
+        \               <div>\n                    <a href=\"/user/vivekanand1101\"\
+        >\n                      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/342d1c36d5c2f6984933a1238581dfbe74a08bf09ccaf110793703f1e8067169?s=20&d=retro\"\
+        />\n                      Vivek Anand (vivekanand1101)\n                 \
+        \   </a>\n                    - ticket\n                  </div>\n       \
+        \           <div>\n                    <a href=\"/user/farhaan\">\n      \
+        \                <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/b681ddfaffa293b03c57e5f0ed90eb1e06a8f748f4dccdccef8cdd301c66c9ae?s=20&d=retro\"\
+        />\n                      Farhaan Bukhsh (farhaan)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/cverna\">\n                \
+        \      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/c7844b93c43c862adaa29920c09bdfb91efed57a843203e5d0b72673e664c003?s=20&d=retro\"\
+        />\n                      Clement Verna (cverna)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/jcline\">\n                \
+        \      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/1a108f46a960aa35efcabd0b779cd59b8c3aba2927213fe63373921e1aa3fac4?s=20&d=retro\"\
+        />\n                      Jeremy Cline (jcline)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/lslebodn\">\n              \
+        \        <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/8c6fcfe0eec099f1f01f5c237935bb5c97f2063a5c1068e57c5815e0bfecbf70?s=20&d=retro\"\
+        />\n                      Lukas Slebodnik (lslebodn)\n                   \
+        \ </a>\n                    - ticket\n                  </div>\n         \
+        \         <div>\n                    <a href=\"/user/mprahl\">\n         \
+        \             <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/ecae5ad9b37619a38e699de00049e449f120b433c9f26a8beb05b990dfc54c9d?s=20&d=retro\"\
+        />\n                      Matt Prahl (mprahl)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n            </div>\n\
+        \            <h5><strong>Branches</strong></h5>\n            <div class=\"\
+        m-b-2\">\n              <div id=\"branch-master\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"\n          \
+        \          title=\"Default branch\"></span>\n                  <strong title=\"\
+        Currently viewing branch master\" data-toggle=\"tooltip\">master</strong>\n\
+        \                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                  <span class=\"oi text-muted\" data-glyph=\"star\"\n \
+        \                   title=\"Default branch\"></span>\n                </div>\n\
+        \              </div>\n\n              <div id=\"branch-2.9.x\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/2.9.x\"\n           \
+        \         title=\"2.9.x\" data-toggle=\"tooltip\">2.9.x\n                \
+        \  </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-alembic_changes\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/alembic_changes\"\n        \
+        \            title=\"alembic_changes\" data-toggle=\"tooltip\">alembic_changes\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-confirm_merge\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/confirm_merge\"\n   \
+        \                 title=\"confirm_merge\" data-toggle=\"tooltip\">confirm_merge\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-css_tables\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/css_tables\"\n      \
+        \              title=\"css_tables\" data-toggle=\"tooltip\">css_tables\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_del_project\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_del_project\"\n \
+        \                   title=\"fix_del_project\" data-toggle=\"tooltip\">fix_del_project\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_delete\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_delete\"\n      \
+        \              title=\"fix_delete\" data-toggle=\"tooltip\">fix_delete\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_diff_refs\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_diff_refs\"\n   \
+        \                 title=\"fix_diff_refs\" data-toggle=\"tooltip\">fix_diff_refs\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_doc\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_doc\"\n                \
+        \    title=\"fix_doc\" data-toggle=\"tooltip\">fix_doc\n                 \
+        \ </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_linking\" class=\"repoinfo-branchlistitem row\">\n\
+        \                <div class=\"branch_name col-md-8\">\n                  <span\
+        \ class=\"oi text-muted\" data-glyph=\"random\"></span>\n                \
+        \  <a class=\"\" href=\"/pagure/branch/fix_linking\"\n                   \
+        \ title=\"fix_linking\" data-toggle=\"tooltip\">fix_linking\n            \
+        \      </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_pygit_error\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_pygit_error\"\n        \
+        \            title=\"fix_pygit_error\" data-toggle=\"tooltip\">fix_pygit_error\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_reply_to\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_reply_to\"\n    \
+        \                title=\"fix_reply_to\" data-toggle=\"tooltip\">fix_reply_to\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_sse\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_sse\"\n                \
+        \    title=\"fix_sse\" data-toggle=\"tooltip\">fix_sse\n                 \
+        \ </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_sse_status\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_sse_status\"\n         \
+        \           title=\"fix_sse_status\" data-toggle=\"tooltip\">fix_sse_status\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_templates\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_templates\"\n   \
+        \                 title=\"fix_templates\" data-toggle=\"tooltip\">fix_templates\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fixes\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fixes\"\n                  \
+        \  title=\"fixes\" data-toggle=\"tooltip\">fixes\n                  </a>\n\
+        \n                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                </div>\n              </div>\n              <div id=\"\
+        branch-force_flag\" class=\"repoinfo-branchlistitem row\">\n             \
+        \   <div class=\"branch_name col-md-8\">\n                  <span class=\"\
+        oi text-muted\" data-glyph=\"random\"></span>\n                  <a class=\"\
+        \" href=\"/pagure/branch/force_flag\"\n                    title=\"force_flag\"\
+        \ data-toggle=\"tooltip\">force_flag\n                  </a>\n\n         \
+        \       </div>\n                <div class=\"branch_del col-md-4\">\n    \
+        \            </div>\n              </div>\n              <div id=\"branch-gpg_check\"\
+        \ class=\"repoinfo-branchlistitem row\">\n                <div class=\"branch_name\
+        \ col-md-8\">\n                  <span class=\"oi text-muted\" data-glyph=\"\
+        random\"></span>\n                  <a class=\"\" href=\"/pagure/branch/gpg_check\"\
+        \n                    title=\"gpg_check\" data-toggle=\"tooltip\">gpg_check\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-logging_milter\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/logging_milter\"\n  \
+        \                  title=\"logging_milter\" data-toggle=\"tooltip\">logging_milter\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-mirroring\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/mirroring\"\n              \
+        \      title=\"mirroring\" data-toggle=\"tooltip\">mirroring\n           \
+        \       </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-py3_work2\" class=\"repoinfo-branchlistitem row\">\n \
+        \               <div class=\"branch_name col-md-8\">\n                  <span\
+        \ class=\"oi text-muted\" data-glyph=\"random\"></span>\n                \
+        \  <a class=\"\" href=\"/pagure/branch/py3_work2\"\n                    title=\"\
+        py3_work2\" data-toggle=\"tooltip\">py3_work2\n                  </a>\n\n\
+        \                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                </div>\n              </div>\n              <div id=\"\
+        branch-rel_2.2.z\" class=\"repoinfo-branchlistitem row\">\n              \
+        \  <div class=\"branch_name col-md-8\">\n                  <span class=\"\
+        oi text-muted\" data-glyph=\"random\"></span>\n                  <a class=\"\
+        \" href=\"/pagure/branch/rel_2.2.z\"\n                    title=\"rel_2.2.z\"\
+        \ data-toggle=\"tooltip\">rel_2.2.z\n                  </a>\n\n          \
+        \      </div>\n                <div class=\"branch_del col-md-4\">\n     \
+        \           </div>\n              </div>\n              <div id=\"branch-rel_2.3.z\"\
+        \ class=\"repoinfo-branchlistitem row\">\n                <div class=\"branch_name\
+        \ col-md-8\">\n                  <span class=\"oi text-muted\" data-glyph=\"\
+        random\"></span>\n                  <a class=\"\" href=\"/pagure/branch/rel_2.3.z\"\
+        \n                    title=\"rel_2.3.z\" data-toggle=\"tooltip\">rel_2.3.z\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-scroll_metadata\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/scroll_metadata\"\n \
+        \                   title=\"scroll_metadata\" data-toggle=\"tooltip\">scroll_metadata\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-utf_search\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/utf_search\"\n      \
+        \              title=\"utf_search\" data-toggle=\"tooltip\">utf_search\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \         </div>\n            <h5><strong>Source GIT URLs</strong>       \
+        \           <span class=\"pull-xs-right\"><a data-toggle=\"collapse\" href=\"\
+        #moregiturls\"\n                  aria-expanded=\"false\" aria-controls=\"\
+        moregiturls\"\n                  id=\"more_gits\">more</a></span></h5>\n \
+        \           <div>\n              <div class=\"form-group\">\n            \
+        \    <div class=\"input-group input-group-sm\">\n                  <div class=\"\
+        input-group-addon\">GIT</div>\n                  <input class=\"form-control\"\
+        \ type=\"text\" value=\"https://pagure.io/pagure.git\" readonly>\n       \
+        \         </div>\n              </div>\n              <div class=\"collapse\"\
+        \ id=\"moregiturls\">\n                  <h5><strong>Docs GIT URLs</strong></h5>\n\
+        \                  <div class=\"form-group\">\n                    <div class=\"\
+        input-group input-group-sm\">\n                      <div class=\"input-group-addon\"\
+        >GIT</div>\n                      <input class=\"form-control\" type=\"text\"\
+        \ value=\"https://pagure.io/docs/pagure.git\" readonly>\n                \
+        \    </div>\n                  </div>\n              </div>\n            </div>\n\
+        \          </div>\n          <div class=\"repo_date\" title=\"2015-05-13 20:38:10.552662\"\
+        >\n              created 2 years ago\n          </div>\n        </div>\n\n\
+        \          <div class=\"card\">\n            <div class=\"card-header\">\n\
+        \              <strong>Recent Commits in\n                <span class=\"pr-toplabel\"\
+        >\n                  <span class=\"oi\" data-glyph=\"random\"></span>\n  \
+        \                master\n                </span>\n              </strong>\n\
+        \            </div>\n            <div class=\"card-block p-a-0\">\n      \
+        \        <div class=\"list-group list-group-flush\">\n                  <a\
+        \ href=\"/pagure/c/a324b4120738bb9765b59625f3e767f9b50d599f\" class=\"list-group-item\"\
+        >\n                    <div class=\"commitdate\" title=\"Sep 25 2017 12:50:46\"\
+        >\n                      <small>\n                        <strong>\n     \
+        \                     <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                          Pierre-Yves Chibon\n                       \
+        \ </strong>\n                        commited 2 days ago\n               \
+        \       </small>\n                    </div>\n                    <div><small>\n\
+        \                      Fix the unit-tests\n                    </small></div>\n\
+        \                  </a>\n                  <a href=\"/pagure/c/ef569a5160891b30cc2aa4d614b39f4a266d7ad7\"\
+        \ class=\"list-group-item\">\n                    <div class=\"commitdate\"\
+        \ title=\"Sep 21 2017 12:15:51\">\n                      <small>\n       \
+        \                 <strong>\n                          <img class=\"avatar\
+        \ circle\" src=\"https://seccdn.libravatar.org/avatar/342d1c36d5c2f6984933a1238581dfbe74a08bf09ccaf110793703f1e8067169?s=20&d=retro\"\
+        />\n                          Vivek Anand\n                        </strong>\n\
+        \                        commited 5 days ago\n                      </small>\n\
+        \                    </div>\n                    <div><small>\n          \
+        \            give project: generate gitolite acls after giving a project\n\
+        \                    </small></div>\n                  </a>\n            \
+        \      <a href=\"/pagure/c/16c5781815d4695fe89db3c5cec5191dc1b81c6f\" class=\"\
+        list-group-item\">\n                    <div class=\"commitdate\" title=\"\
+        Sep 21 2017 10:26:39\">\n                      <small>\n                 \
+        \       <strong>\n                          <img class=\"avatar circle\" src=\"\
+        https://seccdn.libravatar.org/avatar/342d1c36d5c2f6984933a1238581dfbe74a08bf09ccaf110793703f1e8067169?s=20&d=retro\"\
+        />\n                          Vivek Anand\n                        </strong>\n\
+        \                        commited 6 days ago\n                      </small>\n\
+        \                    </div>\n                    <div><small>\n          \
+        \            read only repo: Add unit test for update_read_only_mode method\n\
+        \                    </small></div>\n                  </a>\n            \
+        \  </div>\n              </div>\n            </div>\n          </div>\n  \
+        \    </div>\n    </div>\n\n\n</div>\n    </div>\n\n    <div class=\"footer\
+        \ p-t-1 p-b-1\">\n        <div class=\"container\">\n            <p class=\"\
+        text-muted credit\">\n         Copyright &copy; 2014-2017 Red Hat\n      \
+        \    <a href=\"https://pagure.io/pagure\">pagure</a> &mdash;\n          3.7.1\
+        \ &mdash; <a href=\"https://docs.pagure.org/pagure/usage/index.html\">Documentation</a>\n\
+        \            </p>\n            <p><a href=\"/ssh_info\">SSH Hostkey/Fingerprint</a></p>\n\
+        \        </div>\n    </div>\n\n    <script type=\"text/javascript\"\n    \
+        \    src=\"/static/vendor/jquery/jquery.min.js\">\n    </script>\n    <script\
+        \ type=\"text/javascript\"\n        src=\"/static/vendor/jquery-ui/jquery-ui.min.js\"\
+        >\n    </script>\n    <script type=\"text/javascript\"\n        src=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.js\"\
+        >\n    </script>\n    <script type=\"text/javascript\">\n$('[data-toggle=\"\
+        tooltip\"]').tooltip({placement : 'bottom'});\n    </script>\n\n<script type=\"\
+        text/javascript\">\n$(document).ready(function() {\n        var currentWatchStatusButton\
+        \ = $('#unwatch_button');\n    currentWatchStatusButton.prepend('<span class=\"\
+        oi\" data-glyph=\"circle-check\" style=\"padding-right:0.5em\"></span>');\n\
+        \    $('.watch-menu a').not(currentWatchStatusButton).css('padding-left',\
+        \ '2.85em');\n\n    $(\".watch-menu a\").click(function(){\n        var selectedValue\
+        \ = $(this).attr('id');\n        var watchProjectForm = $(\"#watch_project\"\
+        );\n        var action = watchProjectForm.attr('action');\n\n        if (selectedValue\
+        \ === \"watch_issues_button\") {\n            action = action.replace('/settings/0',\
+        \ '/settings/1');\n        } else if (selectedValue === \"watch_commits_button\"\
+        ) {\n            action = action.replace('/settings/0', '/settings/2');\n\
+        \        } else if (selectedValue === \"watch_issues_commits_button\") {\n\
+        \            action = action.replace('/settings/0', '/settings/3');\n    \
+        \    } else if (selectedValue === \"reset_button\") {\n            action\
+        \ = action.replace('/settings/0', '/settings/-1');\n        }\n\n        watchProjectForm.attr('action',\
+        \ action);\n        watchProjectForm.submit();\n    });\n});\n</script>\n\n\
+        <script type=\"text/javascript\">\n$(document).ready(function() {\n    $(\"\
+        .extra_gits\").hide();\n});\n\n$(function() {\n  $( \"#more_gits\" ).click(function()\
+        \ {\n      if ($( \"#more_gits\" ).html() == 'more') {\n        $( \"#more_gits\"\
+        \ ).html('less');\n      } else {\n        $( \"#more_gits\" ).html('more');\n\
+        \      }\n  });\n\n});\n</script>\n\n\n</body>\n</html>"}
+    headers:
+      Connection: [Keep-Alive]
+      Content-Length: ['31644']
+      Content-Type: [text/html; charset=utf-8]
+      Date: ['Wed, 27 Sep 2017 12:13:40 GMT']
+      Keep-Alive: ['timeout=5, max=100']
+      Server: [Apache/2.4.6 (Red Hat Enterprise Linux) OpenSSL/1.0.1e-fips mod_wsgi/3.4
+          Python/2.7.5]
+      Set-Cookie: ['pagure=eyJfcGVybWFuZW50Ijp0cnVlLCJjc3JmIjp7IiBiIjoiTkdKbVlUZGhZVFU1TmpNeE4yUTVZMkk0WkdFNFpUWm1aVGcyT0RZNVpERTBaRFEyTURnMVlRPT0ifX0.DK0ndA.2Ge7Vf_BJoy4ILHFnc5mQsTH6QQ;
+          Expires=Sat, 28-Oct-2017 12:13:40 GMT; Secure; HttpOnly; Path=/']
+      Strict-Transport-Security: [max-age=15768000; includeSubDomains; preload]
+    status: {code: 200, message: OK}
+version: 1
diff --git a/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidgets.test_get_widgets b/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidgets.test_get_widgets
new file mode 100644
index 0000000..3c51168
--- /dev/null
+++ b/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidgets.test_get_widgets
@@ -0,0 +1,450 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.12.5]
+    method: GET
+    uri: https://pagure.io/pagure
+  response:
+    body: {string: "<!DOCTYPE html>\n<html lang='en'>\n<head>\n    <meta http-equiv=\"\
+        Content-Type\" content=\"text/html; charset=UTF-8\"/>\n    <title>Overview\
+        \ - pagure - Pagure</title>\n    <link rel=\"shortcut icon\" type=\"image/vnd.microsoft.icon\"\
+        \n        href=\"/static/favicon.ico\"/>\n    <link href=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/pagure.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/vendor/open-iconic/css/open-iconic.min.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/fonts/fonts.css\"\
+        \n        rel=\"stylesheet\" type=\"text/css\" />\n    <link href=\"/static/vendor/hack_fonts/css/hack-extended.min.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <meta name=\"go-import\"\
+        \ content=\"pagure.io/pagure git https://pagure.io/pagure.git\">\n  </head>\n\
+        \  <body id=\"home\">\n  <!-- start masthead -->\n    <div class=\"masthead\"\
+        >\n      <div class=\"container\">\n        <div class=\"row\">\n        \
+        \  <div class=\"col-sm-3\">\n            <a href=\"/\">\n              <img\
+        \ height=40px src=\"/static/pagure-logo.png\"\n                alt=\"pagure\
+        \ Logo\" id=\"pagureLogo\"/>\n            </a>\n          </div>\n       \
+        \   <div class=\"col-sm-9\">\n            <div class=\"row\">\n\n  <nav class=\"\
+        navbar navbar-light p-t-0 p-b-0\">\n      <div class=\"container\">\n    \
+        \    <ul class=\"nav navbar-nav nav-underline pull-xs-right\">\n         \
+        \     <li class=\"nav-item p-l-1\">\n                <a class=\"nav-link btn\
+        \ btn-primary\" href=\"/login/?next=https://pagure.io/pagure\">Log In</a>\n\
+        \              </li>\n        </ul>\n      </div>\n    </nav>\n\n        \
+        \    </div>\n          </div>\n        </div>\n      </div>\n    </div><!--\
+        \ close masthead-->\n\n    <div class=\"bodycontent p-b-3\">\n\n\n<div class=\"\
+        repo-header p-t-1\">\n  <div class=\"container\">\n    <header>\n      <h2\
+        \ class=\"repo-name m-b-0\">\n<a href=\"/pagure\">pagure</a>\n        </h2>\n\
+        \        <div class=\"projectinfo m-t-1 m-b-1\">\nA git centered forge &nbsp;|\
+        \ &nbsp;<a class=\"inline\" href=\"https://pagure.io\">https://pagure.io</a>\
+        \        </div>\n\n    </header>\n    <ul class=\"nav nav-tabs nav-small\"\
+        >\n      <li class=\"nav-item\">\n        <a class=\"nav-link active\" href=\"\
+        /pagure\">\n            <span class=\"oi hidden-md-up\" data-glyph=\"home\"\
+        ></span>\n            <span class=\"hidden-sm-down\">Overview</span>\n   \
+        \     </a>\n      </li>\n        <li class=\"nav-item\" >\n          <a  class=\"\
+        nav-link\" href=\"/docs/pagure/\">\n              <span class=\"oi hidden-md-up\"\
+        \ data-glyph=\"book\"></span>\n              <span class=\"hidden-sm-down\"\
+        >Docs</span>\n          </a>\n        </li>\n\n        <li class=\"nav-item\"\
+        >\n          <a class=\"nav-link\" href=\"/pagure/commits/master\">\n    \
+        \          <span class=\"oi hidden-md-up\" data-glyph=\"spreadsheet\">\n \
+        \             </span><span class=\"hidden-sm-down\">Commits</span>\n     \
+        \       </a>\n        </li>\n\n        <li class=\"nav-item\">\n         \
+        \ <a class=\"nav-link\" href=\"/pagure/tree/master\">\n              <span\
+        \ class=\"oi hidden-md-up\" data-glyph=\"file\"></span>\n              <span\
+        \ class=\"hidden-sm-down\">Files</span>\n          </a>\n        </li>\n\n\
+        \        <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"\
+        /pagure/releases\">\n              <span class=\"oi hidden-md-up\" data-glyph=\"\
+        box\"></span>\n              <span class=\"hidden-sm-down\">Releases</span>\n\
+        \          </a>\n        </li>\n\n        <li class=\"nav-item\">\n      \
+        \      <a class=\"nav-link\" href=\"/pagure/issues\">\n                <span\
+        \ class=\"oi hidden-md-up\" data-glyph=\"warning\"></span>\n             \
+        \   <span class=\"hidden-sm-down\">Issues&nbsp;</span>\n                <span\
+        \ class=\"label label-default label-pill hidden-sm-down\">\n             \
+        \     339\n                </span>\n            </a>\n        </li>\n\n  \
+        \      <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"/pagure/pull-requests\"\
+        >\n              <span class=\"oi hidden-md-up\" data-glyph=\"task\"></span>\n\
+        \              <span class=\"hidden-sm-down\">Pull Requests&nbsp;</span>\n\
+        \              <span class=\"label label-default label-pill hidden-sm-down\"\
+        >\n                16\n              </span>\n          </a>\n        </li>\n\
+        \n        <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"\
+        /pagure/forks\">\n              <span class=\"oi hidden-md-up\" data-glyph=\"\
+        fork\"></span>\n              <span class=\"hidden-sm-down\">Forks&nbsp;</span>\n\
+        \              <span class=\"hidden-sm-down label label-default label-pill\"\
+        >\n                131\n              </span>\n            </a>\n        </li>\n\
+        \n\n    </ul>\n  </div>\n</div>\n\n<div class=\"container p-t-3\">\n  <div\
+        \ class=\"row\">\n        <div class=\"col-md-8\">\n            <section class=\"\
+        readme\">\n              <div class=\"document\">\n<div class=\"section\"\
+        >\n<h1>Pagure</h1>\n<table class=\"docutils field-list\">\n<colgroup><col>\n\
+        <col>\n</colgroup><tbody>\n<tr><th>Author:</th><td>Pierre-Yves Chibon &lt;<a\
+        \ href=\"mailto:pingou@pingoured.fr\">pingou@pingoured.fr</a>&gt;</td>\n</tr>\n\
+        </tbody>\n</table>\n<p>Pagure is a git-centered forge, python based using\
+        \ pygit2.</p>\n<p>With pagure you can host your project with its documentation,\
+        \ let your users\nreport issues or request enhancements using the ticketing\
+        \ system and build your\ncommunity of contributors by allowing them to fork\
+        \ your projects and contribute\nto it via the now-popular pull-request mechanism.</p>\n\
+        <p>Homepage: <a href=\"https://pagure.io/pagure\">https://pagure.io/pagure</a></p>\n\
+        <p>See it at work: <a href=\"https://pagure.io\">https://pagure.io</a></p>\n\
+        <p>Playground version: <a href=\"https://stg.pagure.io\">https://stg.pagure.io</a></p>\n\
+        </div>\n<div class=\"section\">\n<h1>Get it running</h1>\n<p>There are several\
+        \ options when it comes to a development environment. Vagrant\nwill provide\
+        \ you with a virtual machine which you can develop on, or you can\ninstall\
+        \ it directly on your host machine.</p>\n<div class=\"section\">\n<h2>Vagrant</h2>\n\
+        <p>For a more thorough introduction to Vagrant, see\n<a href=\"https://fedoraproject.org/wiki/Vagrant\"\
+        >https://fedoraproject.org/wiki/Vagrant</a>.</p>\n<p>An example Vagrantfile\
+        \ is provided as <code>Vagrantfile.example</code>. To use it,\njust copy it\
+        \ and install Vagrant:</p>\n<pre>$ cp dev/Vagrantfile.example Vagrantfile\n\
+        $ sudo dnf install ansible libvirt vagrant-libvirt vagrant-sshfs vagrant-hostmanager\n\
+        $ vagrant up\n</pre>\n<p>The default <code>Vagrantfile</code> forwards ports\
+        \ from the host to the guest,\nso you can interact with the application as\
+        \ if it were running on your\nhost machine.</p>\n<div class=\"note\">\n<p>Note</p>\n\
+        <p><code><span class=\"pre\">vagrant-hostmanager</span></code> will automatically\
+        \ maintain /etc/hosts for you so you\ncan access the development environment\
+        \ from the host using its hostname, which\nby default is <code><span class=\"\
+        pre\">pagure-dev.example.com</span></code>. You can choose not to use this\n\
+        functionality by simply not installing the <code><span class=\"pre\">vagrant-hostmanager</span></code>\
+        \ plugin, but\nif you want Pagure to provide valid URLs in the UI for git\
+        \ repositories, you\nwill need to adjust Pagure's configuration found in ~/pagure.cfg\
+        \ on the guest.</p>\n</div>\n</div>\n<div class=\"section\">\n<h2>Manually</h2>\n\
+        <ul>\n<li><p>Install the needed system libraries:</p>\n<pre>sudo dnf install\
+        \ git python2-virtualenv libgit2-devel \\\n                 libjpeg-devel\
+        \ gcc libffi-devel redhat-rpm-config\n</pre>\n<div class=\"note\">\n<p>Note</p>\n\
+        <p>Do note the version of libgit2 that you install, for example\nin <code><span\
+        \ class=\"pre\">libgit2-0.23.4-1</span></code> you need to keep in mind the\
+        \ <code>0.23</code></p>\n</div>\n<div class=\"note\">\n<p>Note</p>\n<p>On\
+        \ Fedora 23 and earlier or on RHEL and derivative (CentOS,\nScientific Linux)\
+        \ the package <cite>python2-virtualenv</cite> is named\n<cite>python-virtualenv</cite></p>\n\
+        </div>\n</li>\n<li><p>Retrieve the sources:</p>\n<pre>git clone https://pagure.io/pagure.git\n\
+        cd pagure\n</pre>\n</li>\n<li><p>Install dependencies</p>\n<ul>\n<li><p>create\
+        \ the virtualenv:</p>\n<pre>virtualenv pagure_env\nsource ./pagure_env/bin/activate\n\
+        </pre>\n</li>\n<li><p>Install the correct version of pygit2:</p>\n<pre>pip\
+        \ install pygit2==&lt;version of libgit2 found&gt;.*\n</pre>\n<p>So in our\
+        \ example:</p>\n<pre>pip install pygit2==0.23.*\n</pre>\n</li>\n<li><p>Install\
+        \ the rest of the dependencies:</p>\n<pre>pip install -r requirements.txt\n\
+        </pre>\n</li>\n</ul>\n</li>\n<li><p>Create the folder that will receive the\
+        \ projects, forks, docs, requests and\ntickets' git repo:</p>\n<pre>mkdir\
+        \ -p lcl/{repos,docs,forks,tickets,requests,remotes,attachments,releases}\n\
+        </pre>\n</li>\n<li><p>Create the inital database scheme:</p>\n<pre>python\
+        \ createdb.py\n</pre>\n</li>\n<li><p>Start a worker, in one terminal:</p>\n\
+        <pre>./runworker.py\n</pre>\n</li>\n<li><p>Run the application, in another\
+        \ terminal:</p>\n<pre>./runserver.py\n</pre>\n</li>\n<li><p>To get some profiling\
+        \ information you can also run it as:</p>\n<pre>./runserver.py --profile\n\
+        </pre>\n</li>\n</ul>\n<p>This will launch the application at <a href=\"http://127.0.0.1:5000\"\
+        >http://127.0.0.1:5000</a></p>\n<ul>\n<li><p>To run unit-tests on pagure</p>\n\
+        <ul>\n<li><p>Install the dependencies:</p>\n<pre>pip install -r tests_requirements.txt\n\
+        </pre>\n</li>\n<li><p>Run it:</p>\n<pre>./runtests.sh\n</pre>\n<div class=\"\
+        note\">\n<p>Note</p>\n<p>While testing for worker tasks, pagure uses celery\
+        \ in /usr/bin/\nCelery then looks for eventlet (which we use for testing only)\
+        \ at\nsystem level and not in virtualenv. You will need to install eventlet\n\
+        outside of your virtualenv if you are using one.</p>\n</div>\n</li>\n</ul>\n\
+        </li>\n</ul>\n</div>\n</div>\n</div>\n\n            </section>\n        </div>\n\
+        \      <div class=\"col-md-4\">\n        <div class=\"card\">\n          <div\
+        \ class=\"card-block\">\n            <h5><strong>Contributors</strong></h5>\n\
+        \            <div class=\"m-b-2\">\n              <div>\n                <a\
+        \ href=\"/user/pingou\">\n                  <img class=\"avatar circle\" src=\"\
+        https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                  Pierre-YvesChibon (pingou)\n                </a>\n \
+        \               -  main admin\n              </div>\n                  <div>\n\
+        \                    <a href=\"/user/ryanlerch\">\n                      <img\
+        \ class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/de5bf8d06663adb3bb1b8d49ccab259828fad7dddeb233b073d0c447d79b4c14?s=20&d=retro\"\
+        />\n                      Ryan Lerch (ryanlerch)\n                    </a>\n\
+        \                    - admin\n                  </div>\n                 \
+        \ <div>\n                    <a href=\"/user/puiterwijk\">\n             \
+        \         <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/85f8b5f58b9808429ad69ba3722d64da01ce850635e348e838dfdc053e1140e3?s=20&d=retro\"\
+        />\n                      Patrick &#34;\u30DE\u30EB\u30BF\u30A4\u30F3\u30A2\
+        \u30F3\u30C9\u30EC\u30A2\u30B9&#34; Uiterwijk (puiterwijk)\n             \
+        \       </a>\n                    - commit\n                  </div>\n   \
+        \               <div>\n                    <a href=\"/user/vivekanand1101\"\
+        >\n                      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/342d1c36d5c2f6984933a1238581dfbe74a08bf09ccaf110793703f1e8067169?s=20&d=retro\"\
+        />\n                      Vivek Anand (vivekanand1101)\n                 \
+        \   </a>\n                    - ticket\n                  </div>\n       \
+        \           <div>\n                    <a href=\"/user/farhaan\">\n      \
+        \                <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/b681ddfaffa293b03c57e5f0ed90eb1e06a8f748f4dccdccef8cdd301c66c9ae?s=20&d=retro\"\
+        />\n                      Farhaan Bukhsh (farhaan)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/cverna\">\n                \
+        \      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/c7844b93c43c862adaa29920c09bdfb91efed57a843203e5d0b72673e664c003?s=20&d=retro\"\
+        />\n                      Clement Verna (cverna)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/jcline\">\n                \
+        \      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/1a108f46a960aa35efcabd0b779cd59b8c3aba2927213fe63373921e1aa3fac4?s=20&d=retro\"\
+        />\n                      Jeremy Cline (jcline)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/lslebodn\">\n              \
+        \        <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/8c6fcfe0eec099f1f01f5c237935bb5c97f2063a5c1068e57c5815e0bfecbf70?s=20&d=retro\"\
+        />\n                      Lukas Slebodnik (lslebodn)\n                   \
+        \ </a>\n                    - ticket\n                  </div>\n         \
+        \         <div>\n                    <a href=\"/user/mprahl\">\n         \
+        \             <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/ecae5ad9b37619a38e699de00049e449f120b433c9f26a8beb05b990dfc54c9d?s=20&d=retro\"\
+        />\n                      Matt Prahl (mprahl)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n            </div>\n\
+        \            <h5><strong>Branches</strong></h5>\n            <div class=\"\
+        m-b-2\">\n              <div id=\"branch-master\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"\n          \
+        \          title=\"Default branch\"></span>\n                  <strong title=\"\
+        Currently viewing branch master\" data-toggle=\"tooltip\">master</strong>\n\
+        \                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                  <span class=\"oi text-muted\" data-glyph=\"star\"\n \
+        \                   title=\"Default branch\"></span>\n                </div>\n\
+        \              </div>\n\n              <div id=\"branch-2.9.x\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/2.9.x\"\n           \
+        \         title=\"2.9.x\" data-toggle=\"tooltip\">2.9.x\n                \
+        \  </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-alembic_changes\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/alembic_changes\"\n        \
+        \            title=\"alembic_changes\" data-toggle=\"tooltip\">alembic_changes\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-confirm_merge\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/confirm_merge\"\n   \
+        \                 title=\"confirm_merge\" data-toggle=\"tooltip\">confirm_merge\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-css_tables\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/css_tables\"\n      \
+        \              title=\"css_tables\" data-toggle=\"tooltip\">css_tables\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_del_project\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_del_project\"\n \
+        \                   title=\"fix_del_project\" data-toggle=\"tooltip\">fix_del_project\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_delete\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_delete\"\n      \
+        \              title=\"fix_delete\" data-toggle=\"tooltip\">fix_delete\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_diff_refs\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_diff_refs\"\n   \
+        \                 title=\"fix_diff_refs\" data-toggle=\"tooltip\">fix_diff_refs\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_doc\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_doc\"\n                \
+        \    title=\"fix_doc\" data-toggle=\"tooltip\">fix_doc\n                 \
+        \ </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_linking\" class=\"repoinfo-branchlistitem row\">\n\
+        \                <div class=\"branch_name col-md-8\">\n                  <span\
+        \ class=\"oi text-muted\" data-glyph=\"random\"></span>\n                \
+        \  <a class=\"\" href=\"/pagure/branch/fix_linking\"\n                   \
+        \ title=\"fix_linking\" data-toggle=\"tooltip\">fix_linking\n            \
+        \      </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_pygit_error\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_pygit_error\"\n        \
+        \            title=\"fix_pygit_error\" data-toggle=\"tooltip\">fix_pygit_error\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_reply_to\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_reply_to\"\n    \
+        \                title=\"fix_reply_to\" data-toggle=\"tooltip\">fix_reply_to\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_sse\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_sse\"\n                \
+        \    title=\"fix_sse\" data-toggle=\"tooltip\">fix_sse\n                 \
+        \ </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_sse_status\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_sse_status\"\n         \
+        \           title=\"fix_sse_status\" data-toggle=\"tooltip\">fix_sse_status\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_templates\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_templates\"\n   \
+        \                 title=\"fix_templates\" data-toggle=\"tooltip\">fix_templates\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fixes\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fixes\"\n                  \
+        \  title=\"fixes\" data-toggle=\"tooltip\">fixes\n                  </a>\n\
+        \n                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                </div>\n              </div>\n              <div id=\"\
+        branch-force_flag\" class=\"repoinfo-branchlistitem row\">\n             \
+        \   <div class=\"branch_name col-md-8\">\n                  <span class=\"\
+        oi text-muted\" data-glyph=\"random\"></span>\n                  <a class=\"\
+        \" href=\"/pagure/branch/force_flag\"\n                    title=\"force_flag\"\
+        \ data-toggle=\"tooltip\">force_flag\n                  </a>\n\n         \
+        \       </div>\n                <div class=\"branch_del col-md-4\">\n    \
+        \            </div>\n              </div>\n              <div id=\"branch-gpg_check\"\
+        \ class=\"repoinfo-branchlistitem row\">\n                <div class=\"branch_name\
+        \ col-md-8\">\n                  <span class=\"oi text-muted\" data-glyph=\"\
+        random\"></span>\n                  <a class=\"\" href=\"/pagure/branch/gpg_check\"\
+        \n                    title=\"gpg_check\" data-toggle=\"tooltip\">gpg_check\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-logging_milter\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/logging_milter\"\n  \
+        \                  title=\"logging_milter\" data-toggle=\"tooltip\">logging_milter\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-mirroring\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/mirroring\"\n              \
+        \      title=\"mirroring\" data-toggle=\"tooltip\">mirroring\n           \
+        \       </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-py3_work2\" class=\"repoinfo-branchlistitem row\">\n \
+        \               <div class=\"branch_name col-md-8\">\n                  <span\
+        \ class=\"oi text-muted\" data-glyph=\"random\"></span>\n                \
+        \  <a class=\"\" href=\"/pagure/branch/py3_work2\"\n                    title=\"\
+        py3_work2\" data-toggle=\"tooltip\">py3_work2\n                  </a>\n\n\
+        \                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                </div>\n              </div>\n              <div id=\"\
+        branch-rel_2.2.z\" class=\"repoinfo-branchlistitem row\">\n              \
+        \  <div class=\"branch_name col-md-8\">\n                  <span class=\"\
+        oi text-muted\" data-glyph=\"random\"></span>\n                  <a class=\"\
+        \" href=\"/pagure/branch/rel_2.2.z\"\n                    title=\"rel_2.2.z\"\
+        \ data-toggle=\"tooltip\">rel_2.2.z\n                  </a>\n\n          \
+        \      </div>\n                <div class=\"branch_del col-md-4\">\n     \
+        \           </div>\n              </div>\n              <div id=\"branch-rel_2.3.z\"\
+        \ class=\"repoinfo-branchlistitem row\">\n                <div class=\"branch_name\
+        \ col-md-8\">\n                  <span class=\"oi text-muted\" data-glyph=\"\
+        random\"></span>\n                  <a class=\"\" href=\"/pagure/branch/rel_2.3.z\"\
+        \n                    title=\"rel_2.3.z\" data-toggle=\"tooltip\">rel_2.3.z\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-scroll_metadata\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/scroll_metadata\"\n \
+        \                   title=\"scroll_metadata\" data-toggle=\"tooltip\">scroll_metadata\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-utf_search\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/utf_search\"\n      \
+        \              title=\"utf_search\" data-toggle=\"tooltip\">utf_search\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \         </div>\n            <h5><strong>Source GIT URLs</strong>       \
+        \           <span class=\"pull-xs-right\"><a data-toggle=\"collapse\" href=\"\
+        #moregiturls\"\n                  aria-expanded=\"false\" aria-controls=\"\
+        moregiturls\"\n                  id=\"more_gits\">more</a></span></h5>\n \
+        \           <div>\n              <div class=\"form-group\">\n            \
+        \    <div class=\"input-group input-group-sm\">\n                  <div class=\"\
+        input-group-addon\">GIT</div>\n                  <input class=\"form-control\"\
+        \ type=\"text\" value=\"https://pagure.io/pagure.git\" readonly>\n       \
+        \         </div>\n              </div>\n              <div class=\"collapse\"\
+        \ id=\"moregiturls\">\n                  <h5><strong>Docs GIT URLs</strong></h5>\n\
+        \                  <div class=\"form-group\">\n                    <div class=\"\
+        input-group input-group-sm\">\n                      <div class=\"input-group-addon\"\
+        >GIT</div>\n                      <input class=\"form-control\" type=\"text\"\
+        \ value=\"https://pagure.io/docs/pagure.git\" readonly>\n                \
+        \    </div>\n                  </div>\n              </div>\n            </div>\n\
+        \          </div>\n          <div class=\"repo_date\" title=\"2015-05-13 20:38:10.552662\"\
+        >\n              created 2 years ago\n          </div>\n        </div>\n\n\
+        \          <div class=\"card\">\n            <div class=\"card-header\">\n\
+        \              <strong>Recent Commits in\n                <span class=\"pr-toplabel\"\
+        >\n                  <span class=\"oi\" data-glyph=\"random\"></span>\n  \
+        \                master\n                </span>\n              </strong>\n\
+        \            </div>\n            <div class=\"card-block p-a-0\">\n      \
+        \        <div class=\"list-group list-group-flush\">\n                  <a\
+        \ href=\"/pagure/c/75b9021afbb08a3bdc1128cb282ea9c2394c0b67\" class=\"list-group-item\"\
+        >\n                    <div class=\"commitdate\" title=\"Sep 21 2017 08:20:29\"\
+        >\n                      <small>\n                        <strong>\n     \
+        \                     <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                          Pierre-Yves Chibon\n                       \
+        \ </strong>\n                        commited 5 days ago\n               \
+        \       </small>\n                    </div>\n                    <div><small>\n\
+        \                      Add unit-tests for the fixes around loading issue template\n\
+        \                    </small></div>\n                  </a>\n            \
+        \      <a href=\"/pagure/c/fc1d7f885d8b658ce7ba2fbd9cf20692b32f7732\" class=\"\
+        list-group-item\">\n                    <div class=\"commitdate\" title=\"\
+        Sep 21 2017 08:20:29\">\n                      <small>\n                 \
+        \       <strong>\n                          <img class=\"avatar circle\" src=\"\
+        https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                          Pierre-Yves Chibon\n                       \
+        \ </strong>\n                        commited 5 days ago\n               \
+        \       </small>\n                    </div>\n                    <div><small>\n\
+        \                      Flake8 fix fixing the tests\n                    </small></div>\n\
+        \                  </a>\n                  <a href=\"/pagure/c/91c1f357adc77f0b21e15f6177c68ffc97f3ec67\"\
+        \ class=\"list-group-item\">\n                    <div class=\"commitdate\"\
+        \ title=\"Sep 21 2017 08:20:29\">\n                      <small>\n       \
+        \                 <strong>\n                          <img class=\"avatar\
+        \ circle\" src=\"https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                          Pierre-Yves Chibon\n                       \
+        \ </strong>\n                        commited 5 days ago\n               \
+        \       </small>\n                    </div>\n                    <div><small>\n\
+        \                      Adjust the documentation around ticket templates\n\
+        \                    </small></div>\n                  </a>\n            \
+        \  </div>\n              </div>\n            </div>\n          </div>\n  \
+        \    </div>\n    </div>\n\n\n</div>\n    </div>\n\n    <div class=\"footer\
+        \ p-t-1 p-b-1\">\n        <div class=\"container\">\n            <p class=\"\
+        text-muted credit\">\n         Copyright &copy; 2014-2017 Red Hat\n      \
+        \    <a href=\"https://pagure.io/pagure\">pagure</a> &mdash;\n          3.7.1\
+        \ &mdash; <a href=\"https://docs.pagure.org/pagure/usage/index.html\">Documentation</a>\n\
+        \            </p>\n            <p><a href=\"/ssh_info\">SSH Hostkey/Fingerprint</a></p>\n\
+        \        </div>\n    </div>\n\n    <script type=\"text/javascript\"\n    \
+        \    src=\"/static/vendor/jquery/jquery.min.js\">\n    </script>\n    <script\
+        \ type=\"text/javascript\"\n        src=\"/static/vendor/jquery-ui/jquery-ui.min.js\"\
+        >\n    </script>\n    <script type=\"text/javascript\"\n        src=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.js\"\
+        >\n    </script>\n    <script type=\"text/javascript\">\n$('[data-toggle=\"\
+        tooltip\"]').tooltip({placement : 'bottom'});\n    </script>\n\n<script type=\"\
+        text/javascript\">\n$(document).ready(function() {\n        var currentWatchStatusButton\
+        \ = $('#unwatch_button');\n    currentWatchStatusButton.prepend('<span class=\"\
+        oi\" data-glyph=\"circle-check\" style=\"padding-right:0.5em\"></span>');\n\
+        \    $('.watch-menu a').not(currentWatchStatusButton).css('padding-left',\
+        \ '2.85em');\n\n    $(\".watch-menu a\").click(function(){\n        var selectedValue\
+        \ = $(this).attr('id');\n        var watchProjectForm = $(\"#watch_project\"\
+        );\n        var action = watchProjectForm.attr('action');\n\n        if (selectedValue\
+        \ === \"watch_issues_button\") {\n            action = action.replace('/settings/0',\
+        \ '/settings/1');\n        } else if (selectedValue === \"watch_commits_button\"\
+        ) {\n            action = action.replace('/settings/0', '/settings/2');\n\
+        \        } else if (selectedValue === \"watch_issues_commits_button\") {\n\
+        \            action = action.replace('/settings/0', '/settings/3');\n    \
+        \    } else if (selectedValue === \"reset_button\") {\n            action\
+        \ = action.replace('/settings/0', '/settings/-1');\n        }\n\n        watchProjectForm.attr('action',\
+        \ action);\n        watchProjectForm.submit();\n    });\n});\n</script>\n\n\
+        <script type=\"text/javascript\">\n$(document).ready(function() {\n    $(\"\
+        .extra_gits\").hide();\n});\n\n$(function() {\n  $( \"#more_gits\" ).click(function()\
+        \ {\n      if ($( \"#more_gits\" ).html() == 'more') {\n        $( \"#more_gits\"\
+        \ ).html('less');\n      } else {\n        $( \"#more_gits\" ).html('more');\n\
+        \      }\n  });\n\n});\n</script>\n\n\n</body>\n</html>"}
+    headers:
+      Connection: [Keep-Alive]
+      Content-Length: ['31652']
+      Content-Type: [text/html; charset=utf-8]
+      Date: ['Tue, 26 Sep 2017 15:29:35 GMT']
+      Keep-Alive: ['timeout=5, max=100']
+      Server: [Apache/2.4.6 (Red Hat Enterprise Linux) OpenSSL/1.0.1e-fips mod_wsgi/3.4
+          Python/2.7.5]
+      Set-Cookie: ['pagure=eyJfcGVybWFuZW50Ijp0cnVlLCJjc3JmIjp7IiBiIjoiTmprM01qQTJabUZtTVRNd01tTXlORGcyWkRSbE9EQXlZV0poT0Roak1UVmhaalpqTURaaFlRPT0ifX0.DKwD4A.AdJ1dfCC4-moxHJCgtqkdcKir-o;
+          Expires=Fri, 27-Oct-2017 15:29:36 GMT; Secure; HttpOnly; Path=/']
+      Strict-Transport-Security: [max-age=15768000; includeSubDomains; preload]
+    status: {code: 200, message: OK}
+version: 1
diff --git a/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidgets.test_get_widgets_preview b/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidgets.test_get_widgets_preview
new file mode 100644
index 0000000..6146a59
--- /dev/null
+++ b/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidgets.test_get_widgets_preview
@@ -0,0 +1,898 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.12.5]
+    method: GET
+    uri: https://pagure.io/pagure
+  response:
+    body: {string: "<!DOCTYPE html>\n<html lang='en'>\n<head>\n    <meta http-equiv=\"\
+        Content-Type\" content=\"text/html; charset=UTF-8\"/>\n    <title>Overview\
+        \ - pagure - Pagure</title>\n    <link rel=\"shortcut icon\" type=\"image/vnd.microsoft.icon\"\
+        \n        href=\"/static/favicon.ico\"/>\n    <link href=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/pagure.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/vendor/open-iconic/css/open-iconic.min.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/fonts/fonts.css\"\
+        \n        rel=\"stylesheet\" type=\"text/css\" />\n    <link href=\"/static/vendor/hack_fonts/css/hack-extended.min.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <meta name=\"go-import\"\
+        \ content=\"pagure.io/pagure git https://pagure.io/pagure.git\">\n  </head>\n\
+        \  <body id=\"home\">\n  <!-- start masthead -->\n    <div class=\"masthead\"\
+        >\n      <div class=\"container\">\n        <div class=\"row\">\n        \
+        \  <div class=\"col-sm-3\">\n            <a href=\"/\">\n              <img\
+        \ height=40px src=\"/static/pagure-logo.png\"\n                alt=\"pagure\
+        \ Logo\" id=\"pagureLogo\"/>\n            </a>\n          </div>\n       \
+        \   <div class=\"col-sm-9\">\n            <div class=\"row\">\n\n  <nav class=\"\
+        navbar navbar-light p-t-0 p-b-0\">\n      <div class=\"container\">\n    \
+        \    <ul class=\"nav navbar-nav nav-underline pull-xs-right\">\n         \
+        \     <li class=\"nav-item p-l-1\">\n                <a class=\"nav-link btn\
+        \ btn-primary\" href=\"/login/?next=https://pagure.io/pagure\">Log In</a>\n\
+        \              </li>\n        </ul>\n      </div>\n    </nav>\n\n        \
+        \    </div>\n          </div>\n        </div>\n      </div>\n    </div><!--\
+        \ close masthead-->\n\n    <div class=\"bodycontent p-b-3\">\n\n\n<div class=\"\
+        repo-header p-t-1\">\n  <div class=\"container\">\n    <header>\n      <h2\
+        \ class=\"repo-name m-b-0\">\n<a href=\"/pagure\">pagure</a>\n        </h2>\n\
+        \        <div class=\"projectinfo m-t-1 m-b-1\">\nA git centered forge &nbsp;|\
+        \ &nbsp;<a class=\"inline\" href=\"https://pagure.io\">https://pagure.io</a>\
+        \        </div>\n\n    </header>\n    <ul class=\"nav nav-tabs nav-small\"\
+        >\n      <li class=\"nav-item\">\n        <a class=\"nav-link active\" href=\"\
+        /pagure\">\n            <span class=\"oi hidden-md-up\" data-glyph=\"home\"\
+        ></span>\n            <span class=\"hidden-sm-down\">Overview</span>\n   \
+        \     </a>\n      </li>\n        <li class=\"nav-item\" >\n          <a  class=\"\
+        nav-link\" href=\"/docs/pagure/\">\n              <span class=\"oi hidden-md-up\"\
+        \ data-glyph=\"book\"></span>\n              <span class=\"hidden-sm-down\"\
+        >Docs</span>\n          </a>\n        </li>\n\n        <li class=\"nav-item\"\
+        >\n          <a class=\"nav-link\" href=\"/pagure/commits/master\">\n    \
+        \          <span class=\"oi hidden-md-up\" data-glyph=\"spreadsheet\">\n \
+        \             </span><span class=\"hidden-sm-down\">Commits</span>\n     \
+        \       </a>\n        </li>\n\n        <li class=\"nav-item\">\n         \
+        \ <a class=\"nav-link\" href=\"/pagure/tree/master\">\n              <span\
+        \ class=\"oi hidden-md-up\" data-glyph=\"file\"></span>\n              <span\
+        \ class=\"hidden-sm-down\">Files</span>\n          </a>\n        </li>\n\n\
+        \        <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"\
+        /pagure/releases\">\n              <span class=\"oi hidden-md-up\" data-glyph=\"\
+        box\"></span>\n              <span class=\"hidden-sm-down\">Releases</span>\n\
+        \          </a>\n        </li>\n\n        <li class=\"nav-item\">\n      \
+        \      <a class=\"nav-link\" href=\"/pagure/issues\">\n                <span\
+        \ class=\"oi hidden-md-up\" data-glyph=\"warning\"></span>\n             \
+        \   <span class=\"hidden-sm-down\">Issues&nbsp;</span>\n                <span\
+        \ class=\"label label-default label-pill hidden-sm-down\">\n             \
+        \     340\n                </span>\n            </a>\n        </li>\n\n  \
+        \      <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"/pagure/pull-requests\"\
+        >\n              <span class=\"oi hidden-md-up\" data-glyph=\"task\"></span>\n\
+        \              <span class=\"hidden-sm-down\">Pull Requests&nbsp;</span>\n\
+        \              <span class=\"label label-default label-pill hidden-sm-down\"\
+        >\n                15\n              </span>\n          </a>\n        </li>\n\
+        \n        <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"\
+        /pagure/forks\">\n              <span class=\"oi hidden-md-up\" data-glyph=\"\
+        fork\"></span>\n              <span class=\"hidden-sm-down\">Forks&nbsp;</span>\n\
+        \              <span class=\"hidden-sm-down label label-default label-pill\"\
+        >\n                131\n              </span>\n            </a>\n        </li>\n\
+        \n\n    </ul>\n  </div>\n</div>\n\n<div class=\"container p-t-3\">\n  <div\
+        \ class=\"row\">\n        <div class=\"col-md-8\">\n            <section class=\"\
+        readme\">\n              <div class=\"document\">\n<div class=\"section\"\
+        >\n<h1>Pagure</h1>\n<table class=\"docutils field-list\">\n<colgroup><col>\n\
+        <col>\n</colgroup><tbody>\n<tr><th>Author:</th><td>Pierre-Yves Chibon &lt;<a\
+        \ href=\"mailto:pingou@pingoured.fr\">pingou@pingoured.fr</a>&gt;</td>\n</tr>\n\
+        </tbody>\n</table>\n<p>Pagure is a git-centered forge, python based using\
+        \ pygit2.</p>\n<p>With pagure you can host your project with its documentation,\
+        \ let your users\nreport issues or request enhancements using the ticketing\
+        \ system and build your\ncommunity of contributors by allowing them to fork\
+        \ your projects and contribute\nto it via the now-popular pull-request mechanism.</p>\n\
+        <p>Homepage: <a href=\"https://pagure.io/pagure\">https://pagure.io/pagure</a></p>\n\
+        <p>See it at work: <a href=\"https://pagure.io\">https://pagure.io</a></p>\n\
+        <p>Playground version: <a href=\"https://stg.pagure.io\">https://stg.pagure.io</a></p>\n\
+        </div>\n<div class=\"section\">\n<h1>Get it running</h1>\n<p>There are several\
+        \ options when it comes to a development environment. Vagrant\nwill provide\
+        \ you with a virtual machine which you can develop on, or you can\ninstall\
+        \ it directly on your host machine.</p>\n<div class=\"section\">\n<h2>Vagrant</h2>\n\
+        <p>For a more thorough introduction to Vagrant, see\n<a href=\"https://fedoraproject.org/wiki/Vagrant\"\
+        >https://fedoraproject.org/wiki/Vagrant</a>.</p>\n<p>An example Vagrantfile\
+        \ is provided as <code>Vagrantfile.example</code>. To use it,\njust copy it\
+        \ and install Vagrant:</p>\n<pre>$ cp dev/Vagrantfile.example Vagrantfile\n\
+        $ sudo dnf install ansible libvirt vagrant-libvirt vagrant-sshfs vagrant-hostmanager\n\
+        $ vagrant up\n</pre>\n<p>The default <code>Vagrantfile</code> forwards ports\
+        \ from the host to the guest,\nso you can interact with the application as\
+        \ if it were running on your\nhost machine.</p>\n<div class=\"note\">\n<p>Note</p>\n\
+        <p><code><span class=\"pre\">vagrant-hostmanager</span></code> will automatically\
+        \ maintain /etc/hosts for you so you\ncan access the development environment\
+        \ from the host using its hostname, which\nby default is <code><span class=\"\
+        pre\">pagure-dev.example.com</span></code>. You can choose not to use this\n\
+        functionality by simply not installing the <code><span class=\"pre\">vagrant-hostmanager</span></code>\
+        \ plugin, but\nif you want Pagure to provide valid URLs in the UI for git\
+        \ repositories, you\nwill need to adjust Pagure's configuration found in ~/pagure.cfg\
+        \ on the guest.</p>\n</div>\n</div>\n<div class=\"section\">\n<h2>Manually</h2>\n\
+        <ul>\n<li><p>Install the needed system libraries:</p>\n<pre>sudo dnf install\
+        \ git python2-virtualenv libgit2-devel \\\n                 libjpeg-devel\
+        \ gcc libffi-devel redhat-rpm-config\n</pre>\n<div class=\"note\">\n<p>Note</p>\n\
+        <p>Do note the version of libgit2 that you install, for example\nin <code><span\
+        \ class=\"pre\">libgit2-0.23.4-1</span></code> you need to keep in mind the\
+        \ <code>0.23</code></p>\n</div>\n<div class=\"note\">\n<p>Note</p>\n<p>On\
+        \ Fedora 23 and earlier or on RHEL and derivative (CentOS,\nScientific Linux)\
+        \ the package <cite>python2-virtualenv</cite> is named\n<cite>python-virtualenv</cite></p>\n\
+        </div>\n</li>\n<li><p>Retrieve the sources:</p>\n<pre>git clone https://pagure.io/pagure.git\n\
+        cd pagure\n</pre>\n</li>\n<li><p>Install dependencies</p>\n<ul>\n<li><p>create\
+        \ the virtualenv:</p>\n<pre>virtualenv pagure_env\nsource ./pagure_env/bin/activate\n\
+        </pre>\n</li>\n<li><p>Install the correct version of pygit2:</p>\n<pre>pip\
+        \ install pygit2==&lt;version of libgit2 found&gt;.*\n</pre>\n<p>So in our\
+        \ example:</p>\n<pre>pip install pygit2==0.23.*\n</pre>\n</li>\n<li><p>Install\
+        \ the rest of the dependencies:</p>\n<pre>pip install -r requirements.txt\n\
+        </pre>\n</li>\n</ul>\n</li>\n<li><p>Create the folder that will receive the\
+        \ projects, forks, docs, requests and\ntickets' git repo:</p>\n<pre>mkdir\
+        \ -p lcl/{repos,docs,forks,tickets,requests,remotes,attachments,releases}\n\
+        </pre>\n</li>\n<li><p>Create the inital database scheme:</p>\n<pre>python\
+        \ createdb.py\n</pre>\n</li>\n<li><p>Start a worker, in one terminal:</p>\n\
+        <pre>./runworker.py\n</pre>\n</li>\n<li><p>Run the application, in another\
+        \ terminal:</p>\n<pre>./runserver.py\n</pre>\n</li>\n<li><p>To get some profiling\
+        \ information you can also run it as:</p>\n<pre>./runserver.py --profile\n\
+        </pre>\n</li>\n</ul>\n<p>This will launch the application at <a href=\"http://127.0.0.1:5000\"\
+        >http://127.0.0.1:5000</a></p>\n<ul>\n<li><p>To run unit-tests on pagure</p>\n\
+        <ul>\n<li><p>Install the dependencies:</p>\n<pre>pip install -r tests_requirements.txt\n\
+        </pre>\n</li>\n<li><p>Run it:</p>\n<pre>./runtests.sh\n</pre>\n<div class=\"\
+        note\">\n<p>Note</p>\n<p>While testing for worker tasks, pagure uses celery\
+        \ in /usr/bin/\nCelery then looks for eventlet (which we use for testing only)\
+        \ at\nsystem level and not in virtualenv. You will need to install eventlet\n\
+        outside of your virtualenv if you are using one.</p>\n</div>\n</li>\n</ul>\n\
+        </li>\n</ul>\n</div>\n</div>\n</div>\n\n            </section>\n        </div>\n\
+        \      <div class=\"col-md-4\">\n        <div class=\"card\">\n          <div\
+        \ class=\"card-block\">\n            <h5><strong>Contributors</strong></h5>\n\
+        \            <div class=\"m-b-2\">\n              <div>\n                <a\
+        \ href=\"/user/pingou\">\n                  <img class=\"avatar circle\" src=\"\
+        https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                  Pierre-YvesChibon (pingou)\n                </a>\n \
+        \               -  main admin\n              </div>\n                  <div>\n\
+        \                    <a href=\"/user/ryanlerch\">\n                      <img\
+        \ class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/de5bf8d06663adb3bb1b8d49ccab259828fad7dddeb233b073d0c447d79b4c14?s=20&d=retro\"\
+        />\n                      Ryan Lerch (ryanlerch)\n                    </a>\n\
+        \                    - admin\n                  </div>\n                 \
+        \ <div>\n                    <a href=\"/user/puiterwijk\">\n             \
+        \         <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/85f8b5f58b9808429ad69ba3722d64da01ce850635e348e838dfdc053e1140e3?s=20&d=retro\"\
+        />\n                      Patrick &#34;\u30DE\u30EB\u30BF\u30A4\u30F3\u30A2\
+        \u30F3\u30C9\u30EC\u30A2\u30B9&#34; Uiterwijk (puiterwijk)\n             \
+        \       </a>\n                    - commit\n                  </div>\n   \
+        \               <div>\n                    <a href=\"/user/vivekanand1101\"\
+        >\n                      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/342d1c36d5c2f6984933a1238581dfbe74a08bf09ccaf110793703f1e8067169?s=20&d=retro\"\
+        />\n                      Vivek Anand (vivekanand1101)\n                 \
+        \   </a>\n                    - ticket\n                  </div>\n       \
+        \           <div>\n                    <a href=\"/user/farhaan\">\n      \
+        \                <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/b681ddfaffa293b03c57e5f0ed90eb1e06a8f748f4dccdccef8cdd301c66c9ae?s=20&d=retro\"\
+        />\n                      Farhaan Bukhsh (farhaan)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/cverna\">\n                \
+        \      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/c7844b93c43c862adaa29920c09bdfb91efed57a843203e5d0b72673e664c003?s=20&d=retro\"\
+        />\n                      Clement Verna (cverna)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/jcline\">\n                \
+        \      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/1a108f46a960aa35efcabd0b779cd59b8c3aba2927213fe63373921e1aa3fac4?s=20&d=retro\"\
+        />\n                      Jeremy Cline (jcline)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/lslebodn\">\n              \
+        \        <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/8c6fcfe0eec099f1f01f5c237935bb5c97f2063a5c1068e57c5815e0bfecbf70?s=20&d=retro\"\
+        />\n                      Lukas Slebodnik (lslebodn)\n                   \
+        \ </a>\n                    - ticket\n                  </div>\n         \
+        \         <div>\n                    <a href=\"/user/mprahl\">\n         \
+        \             <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/ecae5ad9b37619a38e699de00049e449f120b433c9f26a8beb05b990dfc54c9d?s=20&d=retro\"\
+        />\n                      Matt Prahl (mprahl)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n            </div>\n\
+        \            <h5><strong>Branches</strong></h5>\n            <div class=\"\
+        m-b-2\">\n              <div id=\"branch-master\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"\n          \
+        \          title=\"Default branch\"></span>\n                  <strong title=\"\
+        Currently viewing branch master\" data-toggle=\"tooltip\">master</strong>\n\
+        \                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                  <span class=\"oi text-muted\" data-glyph=\"star\"\n \
+        \                   title=\"Default branch\"></span>\n                </div>\n\
+        \              </div>\n\n              <div id=\"branch-2.9.x\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/2.9.x\"\n           \
+        \         title=\"2.9.x\" data-toggle=\"tooltip\">2.9.x\n                \
+        \  </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-alembic_changes\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/alembic_changes\"\n        \
+        \            title=\"alembic_changes\" data-toggle=\"tooltip\">alembic_changes\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-confirm_merge\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/confirm_merge\"\n   \
+        \                 title=\"confirm_merge\" data-toggle=\"tooltip\">confirm_merge\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-css_tables\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/css_tables\"\n      \
+        \              title=\"css_tables\" data-toggle=\"tooltip\">css_tables\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_del_project\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_del_project\"\n \
+        \                   title=\"fix_del_project\" data-toggle=\"tooltip\">fix_del_project\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_delete\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_delete\"\n      \
+        \              title=\"fix_delete\" data-toggle=\"tooltip\">fix_delete\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_diff_refs\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_diff_refs\"\n   \
+        \                 title=\"fix_diff_refs\" data-toggle=\"tooltip\">fix_diff_refs\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_doc\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_doc\"\n                \
+        \    title=\"fix_doc\" data-toggle=\"tooltip\">fix_doc\n                 \
+        \ </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_linking\" class=\"repoinfo-branchlistitem row\">\n\
+        \                <div class=\"branch_name col-md-8\">\n                  <span\
+        \ class=\"oi text-muted\" data-glyph=\"random\"></span>\n                \
+        \  <a class=\"\" href=\"/pagure/branch/fix_linking\"\n                   \
+        \ title=\"fix_linking\" data-toggle=\"tooltip\">fix_linking\n            \
+        \      </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_pygit_error\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_pygit_error\"\n        \
+        \            title=\"fix_pygit_error\" data-toggle=\"tooltip\">fix_pygit_error\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_reply_to\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_reply_to\"\n    \
+        \                title=\"fix_reply_to\" data-toggle=\"tooltip\">fix_reply_to\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_sse\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_sse\"\n                \
+        \    title=\"fix_sse\" data-toggle=\"tooltip\">fix_sse\n                 \
+        \ </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_sse_status\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_sse_status\"\n         \
+        \           title=\"fix_sse_status\" data-toggle=\"tooltip\">fix_sse_status\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_templates\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_templates\"\n   \
+        \                 title=\"fix_templates\" data-toggle=\"tooltip\">fix_templates\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fixes\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fixes\"\n                  \
+        \  title=\"fixes\" data-toggle=\"tooltip\">fixes\n                  </a>\n\
+        \n                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                </div>\n              </div>\n              <div id=\"\
+        branch-force_flag\" class=\"repoinfo-branchlistitem row\">\n             \
+        \   <div class=\"branch_name col-md-8\">\n                  <span class=\"\
+        oi text-muted\" data-glyph=\"random\"></span>\n                  <a class=\"\
+        \" href=\"/pagure/branch/force_flag\"\n                    title=\"force_flag\"\
+        \ data-toggle=\"tooltip\">force_flag\n                  </a>\n\n         \
+        \       </div>\n                <div class=\"branch_del col-md-4\">\n    \
+        \            </div>\n              </div>\n              <div id=\"branch-gpg_check\"\
+        \ class=\"repoinfo-branchlistitem row\">\n                <div class=\"branch_name\
+        \ col-md-8\">\n                  <span class=\"oi text-muted\" data-glyph=\"\
+        random\"></span>\n                  <a class=\"\" href=\"/pagure/branch/gpg_check\"\
+        \n                    title=\"gpg_check\" data-toggle=\"tooltip\">gpg_check\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-logging_milter\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/logging_milter\"\n  \
+        \                  title=\"logging_milter\" data-toggle=\"tooltip\">logging_milter\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-mirroring\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/mirroring\"\n              \
+        \      title=\"mirroring\" data-toggle=\"tooltip\">mirroring\n           \
+        \       </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-py3_work2\" class=\"repoinfo-branchlistitem row\">\n \
+        \               <div class=\"branch_name col-md-8\">\n                  <span\
+        \ class=\"oi text-muted\" data-glyph=\"random\"></span>\n                \
+        \  <a class=\"\" href=\"/pagure/branch/py3_work2\"\n                    title=\"\
+        py3_work2\" data-toggle=\"tooltip\">py3_work2\n                  </a>\n\n\
+        \                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                </div>\n              </div>\n              <div id=\"\
+        branch-rel_2.2.z\" class=\"repoinfo-branchlistitem row\">\n              \
+        \  <div class=\"branch_name col-md-8\">\n                  <span class=\"\
+        oi text-muted\" data-glyph=\"random\"></span>\n                  <a class=\"\
+        \" href=\"/pagure/branch/rel_2.2.z\"\n                    title=\"rel_2.2.z\"\
+        \ data-toggle=\"tooltip\">rel_2.2.z\n                  </a>\n\n          \
+        \      </div>\n                <div class=\"branch_del col-md-4\">\n     \
+        \           </div>\n              </div>\n              <div id=\"branch-rel_2.3.z\"\
+        \ class=\"repoinfo-branchlistitem row\">\n                <div class=\"branch_name\
+        \ col-md-8\">\n                  <span class=\"oi text-muted\" data-glyph=\"\
+        random\"></span>\n                  <a class=\"\" href=\"/pagure/branch/rel_2.3.z\"\
+        \n                    title=\"rel_2.3.z\" data-toggle=\"tooltip\">rel_2.3.z\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-scroll_metadata\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/scroll_metadata\"\n \
+        \                   title=\"scroll_metadata\" data-toggle=\"tooltip\">scroll_metadata\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-utf_search\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/utf_search\"\n      \
+        \              title=\"utf_search\" data-toggle=\"tooltip\">utf_search\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \         </div>\n            <h5><strong>Source GIT URLs</strong>       \
+        \           <span class=\"pull-xs-right\"><a data-toggle=\"collapse\" href=\"\
+        #moregiturls\"\n                  aria-expanded=\"false\" aria-controls=\"\
+        moregiturls\"\n                  id=\"more_gits\">more</a></span></h5>\n \
+        \           <div>\n              <div class=\"form-group\">\n            \
+        \    <div class=\"input-group input-group-sm\">\n                  <div class=\"\
+        input-group-addon\">GIT</div>\n                  <input class=\"form-control\"\
+        \ type=\"text\" value=\"https://pagure.io/pagure.git\" readonly>\n       \
+        \         </div>\n              </div>\n              <div class=\"collapse\"\
+        \ id=\"moregiturls\">\n                  <h5><strong>Docs GIT URLs</strong></h5>\n\
+        \                  <div class=\"form-group\">\n                    <div class=\"\
+        input-group input-group-sm\">\n                      <div class=\"input-group-addon\"\
+        >GIT</div>\n                      <input class=\"form-control\" type=\"text\"\
+        \ value=\"https://pagure.io/docs/pagure.git\" readonly>\n                \
+        \    </div>\n                  </div>\n              </div>\n            </div>\n\
+        \          </div>\n          <div class=\"repo_date\" title=\"2015-05-13 20:38:10.552662\"\
+        >\n              created 2 years ago\n          </div>\n        </div>\n\n\
+        \          <div class=\"card\">\n            <div class=\"card-header\">\n\
+        \              <strong>Recent Commits in\n                <span class=\"pr-toplabel\"\
+        >\n                  <span class=\"oi\" data-glyph=\"random\"></span>\n  \
+        \                master\n                </span>\n              </strong>\n\
+        \            </div>\n            <div class=\"card-block p-a-0\">\n      \
+        \        <div class=\"list-group list-group-flush\">\n                  <a\
+        \ href=\"/pagure/c/a324b4120738bb9765b59625f3e767f9b50d599f\" class=\"list-group-item\"\
+        >\n                    <div class=\"commitdate\" title=\"Sep 25 2017 12:50:46\"\
+        >\n                      <small>\n                        <strong>\n     \
+        \                     <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                          Pierre-Yves Chibon\n                       \
+        \ </strong>\n                        commited 2 days ago\n               \
+        \       </small>\n                    </div>\n                    <div><small>\n\
+        \                      Fix the unit-tests\n                    </small></div>\n\
+        \                  </a>\n                  <a href=\"/pagure/c/ef569a5160891b30cc2aa4d614b39f4a266d7ad7\"\
+        \ class=\"list-group-item\">\n                    <div class=\"commitdate\"\
+        \ title=\"Sep 21 2017 12:15:51\">\n                      <small>\n       \
+        \                 <strong>\n                          <img class=\"avatar\
+        \ circle\" src=\"https://seccdn.libravatar.org/avatar/342d1c36d5c2f6984933a1238581dfbe74a08bf09ccaf110793703f1e8067169?s=20&d=retro\"\
+        />\n                          Vivek Anand\n                        </strong>\n\
+        \                        commited 5 days ago\n                      </small>\n\
+        \                    </div>\n                    <div><small>\n          \
+        \            give project: generate gitolite acls after giving a project\n\
+        \                    </small></div>\n                  </a>\n            \
+        \      <a href=\"/pagure/c/16c5781815d4695fe89db3c5cec5191dc1b81c6f\" class=\"\
+        list-group-item\">\n                    <div class=\"commitdate\" title=\"\
+        Sep 21 2017 10:26:39\">\n                      <small>\n                 \
+        \       <strong>\n                          <img class=\"avatar circle\" src=\"\
+        https://seccdn.libravatar.org/avatar/342d1c36d5c2f6984933a1238581dfbe74a08bf09ccaf110793703f1e8067169?s=20&d=retro\"\
+        />\n                          Vivek Anand\n                        </strong>\n\
+        \                        commited 6 days ago\n                      </small>\n\
+        \                    </div>\n                    <div><small>\n          \
+        \            read only repo: Add unit test for update_read_only_mode method\n\
+        \                    </small></div>\n                  </a>\n            \
+        \  </div>\n              </div>\n            </div>\n          </div>\n  \
+        \    </div>\n    </div>\n\n\n</div>\n    </div>\n\n    <div class=\"footer\
+        \ p-t-1 p-b-1\">\n        <div class=\"container\">\n            <p class=\"\
+        text-muted credit\">\n         Copyright &copy; 2014-2017 Red Hat\n      \
+        \    <a href=\"https://pagure.io/pagure\">pagure</a> &mdash;\n          3.7.1\
+        \ &mdash; <a href=\"https://docs.pagure.org/pagure/usage/index.html\">Documentation</a>\n\
+        \            </p>\n            <p><a href=\"/ssh_info\">SSH Hostkey/Fingerprint</a></p>\n\
+        \        </div>\n    </div>\n\n    <script type=\"text/javascript\"\n    \
+        \    src=\"/static/vendor/jquery/jquery.min.js\">\n    </script>\n    <script\
+        \ type=\"text/javascript\"\n        src=\"/static/vendor/jquery-ui/jquery-ui.min.js\"\
+        >\n    </script>\n    <script type=\"text/javascript\"\n        src=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.js\"\
+        >\n    </script>\n    <script type=\"text/javascript\">\n$('[data-toggle=\"\
+        tooltip\"]').tooltip({placement : 'bottom'});\n    </script>\n\n<script type=\"\
+        text/javascript\">\n$(document).ready(function() {\n        var currentWatchStatusButton\
+        \ = $('#unwatch_button');\n    currentWatchStatusButton.prepend('<span class=\"\
+        oi\" data-glyph=\"circle-check\" style=\"padding-right:0.5em\"></span>');\n\
+        \    $('.watch-menu a').not(currentWatchStatusButton).css('padding-left',\
+        \ '2.85em');\n\n    $(\".watch-menu a\").click(function(){\n        var selectedValue\
+        \ = $(this).attr('id');\n        var watchProjectForm = $(\"#watch_project\"\
+        );\n        var action = watchProjectForm.attr('action');\n\n        if (selectedValue\
+        \ === \"watch_issues_button\") {\n            action = action.replace('/settings/0',\
+        \ '/settings/1');\n        } else if (selectedValue === \"watch_commits_button\"\
+        ) {\n            action = action.replace('/settings/0', '/settings/2');\n\
+        \        } else if (selectedValue === \"watch_issues_commits_button\") {\n\
+        \            action = action.replace('/settings/0', '/settings/3');\n    \
+        \    } else if (selectedValue === \"reset_button\") {\n            action\
+        \ = action.replace('/settings/0', '/settings/-1');\n        }\n\n        watchProjectForm.attr('action',\
+        \ action);\n        watchProjectForm.submit();\n    });\n});\n</script>\n\n\
+        <script type=\"text/javascript\">\n$(document).ready(function() {\n    $(\"\
+        .extra_gits\").hide();\n});\n\n$(function() {\n  $( \"#more_gits\" ).click(function()\
+        \ {\n      if ($( \"#more_gits\" ).html() == 'more') {\n        $( \"#more_gits\"\
+        \ ).html('less');\n      } else {\n        $( \"#more_gits\" ).html('more');\n\
+        \      }\n  });\n\n});\n</script>\n\n\n</body>\n</html>"}
+    headers:
+      Connection: [Keep-Alive]
+      Content-Length: ['31644']
+      Content-Type: [text/html; charset=utf-8]
+      Date: ['Wed, 27 Sep 2017 12:07:28 GMT']
+      Keep-Alive: ['timeout=5, max=100']
+      Server: [Apache/2.4.6 (Red Hat Enterprise Linux) OpenSSL/1.0.1e-fips mod_wsgi/3.4
+          Python/2.7.5]
+      Set-Cookie: ['pagure=eyJfcGVybWFuZW50Ijp0cnVlLCJjc3JmIjp7IiBiIjoiWldWa1lqZ3lNVFpoT1RBNE5qSmhZemRsTURNeU56TmxNekExTXpnNVlUZGxPV016TlRVMVpnPT0ifX0.DK0mAQ.YtILWDOD3ZiRyLDyFcyzpcGTfaM;
+          Expires=Sat, 28-Oct-2017 12:07:29 GMT; Secure; HttpOnly; Path=/']
+      Strict-Transport-Security: [max-age=15768000; includeSubDomains; preload]
+    status: {code: 200, message: OK}
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.12.5]
+    method: GET
+    uri: https://pagure.io/pagure
+  response:
+    body: {string: "<!DOCTYPE html>\n<html lang='en'>\n<head>\n    <meta http-equiv=\"\
+        Content-Type\" content=\"text/html; charset=UTF-8\"/>\n    <title>Overview\
+        \ - pagure - Pagure</title>\n    <link rel=\"shortcut icon\" type=\"image/vnd.microsoft.icon\"\
+        \n        href=\"/static/favicon.ico\"/>\n    <link href=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/pagure.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/vendor/open-iconic/css/open-iconic.min.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/fonts/fonts.css\"\
+        \n        rel=\"stylesheet\" type=\"text/css\" />\n    <link href=\"/static/vendor/hack_fonts/css/hack-extended.min.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <meta name=\"go-import\"\
+        \ content=\"pagure.io/pagure git https://pagure.io/pagure.git\">\n  </head>\n\
+        \  <body id=\"home\">\n  <!-- start masthead -->\n    <div class=\"masthead\"\
+        >\n      <div class=\"container\">\n        <div class=\"row\">\n        \
+        \  <div class=\"col-sm-3\">\n            <a href=\"/\">\n              <img\
+        \ height=40px src=\"/static/pagure-logo.png\"\n                alt=\"pagure\
+        \ Logo\" id=\"pagureLogo\"/>\n            </a>\n          </div>\n       \
+        \   <div class=\"col-sm-9\">\n            <div class=\"row\">\n\n  <nav class=\"\
+        navbar navbar-light p-t-0 p-b-0\">\n      <div class=\"container\">\n    \
+        \    <ul class=\"nav navbar-nav nav-underline pull-xs-right\">\n         \
+        \     <li class=\"nav-item p-l-1\">\n                <a class=\"nav-link btn\
+        \ btn-primary\" href=\"/login/?next=https://pagure.io/pagure\">Log In</a>\n\
+        \              </li>\n        </ul>\n      </div>\n    </nav>\n\n        \
+        \    </div>\n          </div>\n        </div>\n      </div>\n    </div><!--\
+        \ close masthead-->\n\n    <div class=\"bodycontent p-b-3\">\n\n\n<div class=\"\
+        repo-header p-t-1\">\n  <div class=\"container\">\n    <header>\n      <h2\
+        \ class=\"repo-name m-b-0\">\n<a href=\"/pagure\">pagure</a>\n        </h2>\n\
+        \        <div class=\"projectinfo m-t-1 m-b-1\">\nA git centered forge &nbsp;|\
+        \ &nbsp;<a class=\"inline\" href=\"https://pagure.io\">https://pagure.io</a>\
+        \        </div>\n\n    </header>\n    <ul class=\"nav nav-tabs nav-small\"\
+        >\n      <li class=\"nav-item\">\n        <a class=\"nav-link active\" href=\"\
+        /pagure\">\n            <span class=\"oi hidden-md-up\" data-glyph=\"home\"\
+        ></span>\n            <span class=\"hidden-sm-down\">Overview</span>\n   \
+        \     </a>\n      </li>\n        <li class=\"nav-item\" >\n          <a  class=\"\
+        nav-link\" href=\"/docs/pagure/\">\n              <span class=\"oi hidden-md-up\"\
+        \ data-glyph=\"book\"></span>\n              <span class=\"hidden-sm-down\"\
+        >Docs</span>\n          </a>\n        </li>\n\n        <li class=\"nav-item\"\
+        >\n          <a class=\"nav-link\" href=\"/pagure/commits/master\">\n    \
+        \          <span class=\"oi hidden-md-up\" data-glyph=\"spreadsheet\">\n \
+        \             </span><span class=\"hidden-sm-down\">Commits</span>\n     \
+        \       </a>\n        </li>\n\n        <li class=\"nav-item\">\n         \
+        \ <a class=\"nav-link\" href=\"/pagure/tree/master\">\n              <span\
+        \ class=\"oi hidden-md-up\" data-glyph=\"file\"></span>\n              <span\
+        \ class=\"hidden-sm-down\">Files</span>\n          </a>\n        </li>\n\n\
+        \        <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"\
+        /pagure/releases\">\n              <span class=\"oi hidden-md-up\" data-glyph=\"\
+        box\"></span>\n              <span class=\"hidden-sm-down\">Releases</span>\n\
+        \          </a>\n        </li>\n\n        <li class=\"nav-item\">\n      \
+        \      <a class=\"nav-link\" href=\"/pagure/issues\">\n                <span\
+        \ class=\"oi hidden-md-up\" data-glyph=\"warning\"></span>\n             \
+        \   <span class=\"hidden-sm-down\">Issues&nbsp;</span>\n                <span\
+        \ class=\"label label-default label-pill hidden-sm-down\">\n             \
+        \     340\n                </span>\n            </a>\n        </li>\n\n  \
+        \      <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"/pagure/pull-requests\"\
+        >\n              <span class=\"oi hidden-md-up\" data-glyph=\"task\"></span>\n\
+        \              <span class=\"hidden-sm-down\">Pull Requests&nbsp;</span>\n\
+        \              <span class=\"label label-default label-pill hidden-sm-down\"\
+        >\n                15\n              </span>\n          </a>\n        </li>\n\
+        \n        <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"\
+        /pagure/forks\">\n              <span class=\"oi hidden-md-up\" data-glyph=\"\
+        fork\"></span>\n              <span class=\"hidden-sm-down\">Forks&nbsp;</span>\n\
+        \              <span class=\"hidden-sm-down label label-default label-pill\"\
+        >\n                131\n              </span>\n            </a>\n        </li>\n\
+        \n\n    </ul>\n  </div>\n</div>\n\n<div class=\"container p-t-3\">\n  <div\
+        \ class=\"row\">\n        <div class=\"col-md-8\">\n            <section class=\"\
+        readme\">\n              <div class=\"document\">\n<div class=\"section\"\
+        >\n<h1>Pagure</h1>\n<table class=\"docutils field-list\">\n<colgroup><col>\n\
+        <col>\n</colgroup><tbody>\n<tr><th>Author:</th><td>Pierre-Yves Chibon &lt;<a\
+        \ href=\"mailto:pingou@pingoured.fr\">pingou@pingoured.fr</a>&gt;</td>\n</tr>\n\
+        </tbody>\n</table>\n<p>Pagure is a git-centered forge, python based using\
+        \ pygit2.</p>\n<p>With pagure you can host your project with its documentation,\
+        \ let your users\nreport issues or request enhancements using the ticketing\
+        \ system and build your\ncommunity of contributors by allowing them to fork\
+        \ your projects and contribute\nto it via the now-popular pull-request mechanism.</p>\n\
+        <p>Homepage: <a href=\"https://pagure.io/pagure\">https://pagure.io/pagure</a></p>\n\
+        <p>See it at work: <a href=\"https://pagure.io\">https://pagure.io</a></p>\n\
+        <p>Playground version: <a href=\"https://stg.pagure.io\">https://stg.pagure.io</a></p>\n\
+        </div>\n<div class=\"section\">\n<h1>Get it running</h1>\n<p>There are several\
+        \ options when it comes to a development environment. Vagrant\nwill provide\
+        \ you with a virtual machine which you can develop on, or you can\ninstall\
+        \ it directly on your host machine.</p>\n<div class=\"section\">\n<h2>Vagrant</h2>\n\
+        <p>For a more thorough introduction to Vagrant, see\n<a href=\"https://fedoraproject.org/wiki/Vagrant\"\
+        >https://fedoraproject.org/wiki/Vagrant</a>.</p>\n<p>An example Vagrantfile\
+        \ is provided as <code>Vagrantfile.example</code>. To use it,\njust copy it\
+        \ and install Vagrant:</p>\n<pre>$ cp dev/Vagrantfile.example Vagrantfile\n\
+        $ sudo dnf install ansible libvirt vagrant-libvirt vagrant-sshfs vagrant-hostmanager\n\
+        $ vagrant up\n</pre>\n<p>The default <code>Vagrantfile</code> forwards ports\
+        \ from the host to the guest,\nso you can interact with the application as\
+        \ if it were running on your\nhost machine.</p>\n<div class=\"note\">\n<p>Note</p>\n\
+        <p><code><span class=\"pre\">vagrant-hostmanager</span></code> will automatically\
+        \ maintain /etc/hosts for you so you\ncan access the development environment\
+        \ from the host using its hostname, which\nby default is <code><span class=\"\
+        pre\">pagure-dev.example.com</span></code>. You can choose not to use this\n\
+        functionality by simply not installing the <code><span class=\"pre\">vagrant-hostmanager</span></code>\
+        \ plugin, but\nif you want Pagure to provide valid URLs in the UI for git\
+        \ repositories, you\nwill need to adjust Pagure's configuration found in ~/pagure.cfg\
+        \ on the guest.</p>\n</div>\n</div>\n<div class=\"section\">\n<h2>Manually</h2>\n\
+        <ul>\n<li><p>Install the needed system libraries:</p>\n<pre>sudo dnf install\
+        \ git python2-virtualenv libgit2-devel \\\n                 libjpeg-devel\
+        \ gcc libffi-devel redhat-rpm-config\n</pre>\n<div class=\"note\">\n<p>Note</p>\n\
+        <p>Do note the version of libgit2 that you install, for example\nin <code><span\
+        \ class=\"pre\">libgit2-0.23.4-1</span></code> you need to keep in mind the\
+        \ <code>0.23</code></p>\n</div>\n<div class=\"note\">\n<p>Note</p>\n<p>On\
+        \ Fedora 23 and earlier or on RHEL and derivative (CentOS,\nScientific Linux)\
+        \ the package <cite>python2-virtualenv</cite> is named\n<cite>python-virtualenv</cite></p>\n\
+        </div>\n</li>\n<li><p>Retrieve the sources:</p>\n<pre>git clone https://pagure.io/pagure.git\n\
+        cd pagure\n</pre>\n</li>\n<li><p>Install dependencies</p>\n<ul>\n<li><p>create\
+        \ the virtualenv:</p>\n<pre>virtualenv pagure_env\nsource ./pagure_env/bin/activate\n\
+        </pre>\n</li>\n<li><p>Install the correct version of pygit2:</p>\n<pre>pip\
+        \ install pygit2==&lt;version of libgit2 found&gt;.*\n</pre>\n<p>So in our\
+        \ example:</p>\n<pre>pip install pygit2==0.23.*\n</pre>\n</li>\n<li><p>Install\
+        \ the rest of the dependencies:</p>\n<pre>pip install -r requirements.txt\n\
+        </pre>\n</li>\n</ul>\n</li>\n<li><p>Create the folder that will receive the\
+        \ projects, forks, docs, requests and\ntickets' git repo:</p>\n<pre>mkdir\
+        \ -p lcl/{repos,docs,forks,tickets,requests,remotes,attachments,releases}\n\
+        </pre>\n</li>\n<li><p>Create the inital database scheme:</p>\n<pre>python\
+        \ createdb.py\n</pre>\n</li>\n<li><p>Start a worker, in one terminal:</p>\n\
+        <pre>./runworker.py\n</pre>\n</li>\n<li><p>Run the application, in another\
+        \ terminal:</p>\n<pre>./runserver.py\n</pre>\n</li>\n<li><p>To get some profiling\
+        \ information you can also run it as:</p>\n<pre>./runserver.py --profile\n\
+        </pre>\n</li>\n</ul>\n<p>This will launch the application at <a href=\"http://127.0.0.1:5000\"\
+        >http://127.0.0.1:5000</a></p>\n<ul>\n<li><p>To run unit-tests on pagure</p>\n\
+        <ul>\n<li><p>Install the dependencies:</p>\n<pre>pip install -r tests_requirements.txt\n\
+        </pre>\n</li>\n<li><p>Run it:</p>\n<pre>./runtests.sh\n</pre>\n<div class=\"\
+        note\">\n<p>Note</p>\n<p>While testing for worker tasks, pagure uses celery\
+        \ in /usr/bin/\nCelery then looks for eventlet (which we use for testing only)\
+        \ at\nsystem level and not in virtualenv. You will need to install eventlet\n\
+        outside of your virtualenv if you are using one.</p>\n</div>\n</li>\n</ul>\n\
+        </li>\n</ul>\n</div>\n</div>\n</div>\n\n            </section>\n        </div>\n\
+        \      <div class=\"col-md-4\">\n        <div class=\"card\">\n          <div\
+        \ class=\"card-block\">\n            <h5><strong>Contributors</strong></h5>\n\
+        \            <div class=\"m-b-2\">\n              <div>\n                <a\
+        \ href=\"/user/pingou\">\n                  <img class=\"avatar circle\" src=\"\
+        https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                  Pierre-YvesChibon (pingou)\n                </a>\n \
+        \               -  main admin\n              </div>\n                  <div>\n\
+        \                    <a href=\"/user/ryanlerch\">\n                      <img\
+        \ class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/de5bf8d06663adb3bb1b8d49ccab259828fad7dddeb233b073d0c447d79b4c14?s=20&d=retro\"\
+        />\n                      Ryan Lerch (ryanlerch)\n                    </a>\n\
+        \                    - admin\n                  </div>\n                 \
+        \ <div>\n                    <a href=\"/user/puiterwijk\">\n             \
+        \         <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/85f8b5f58b9808429ad69ba3722d64da01ce850635e348e838dfdc053e1140e3?s=20&d=retro\"\
+        />\n                      Patrick &#34;\u30DE\u30EB\u30BF\u30A4\u30F3\u30A2\
+        \u30F3\u30C9\u30EC\u30A2\u30B9&#34; Uiterwijk (puiterwijk)\n             \
+        \       </a>\n                    - commit\n                  </div>\n   \
+        \               <div>\n                    <a href=\"/user/vivekanand1101\"\
+        >\n                      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/342d1c36d5c2f6984933a1238581dfbe74a08bf09ccaf110793703f1e8067169?s=20&d=retro\"\
+        />\n                      Vivek Anand (vivekanand1101)\n                 \
+        \   </a>\n                    - ticket\n                  </div>\n       \
+        \           <div>\n                    <a href=\"/user/farhaan\">\n      \
+        \                <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/b681ddfaffa293b03c57e5f0ed90eb1e06a8f748f4dccdccef8cdd301c66c9ae?s=20&d=retro\"\
+        />\n                      Farhaan Bukhsh (farhaan)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/cverna\">\n                \
+        \      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/c7844b93c43c862adaa29920c09bdfb91efed57a843203e5d0b72673e664c003?s=20&d=retro\"\
+        />\n                      Clement Verna (cverna)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/jcline\">\n                \
+        \      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/1a108f46a960aa35efcabd0b779cd59b8c3aba2927213fe63373921e1aa3fac4?s=20&d=retro\"\
+        />\n                      Jeremy Cline (jcline)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/lslebodn\">\n              \
+        \        <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/8c6fcfe0eec099f1f01f5c237935bb5c97f2063a5c1068e57c5815e0bfecbf70?s=20&d=retro\"\
+        />\n                      Lukas Slebodnik (lslebodn)\n                   \
+        \ </a>\n                    - ticket\n                  </div>\n         \
+        \         <div>\n                    <a href=\"/user/mprahl\">\n         \
+        \             <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/ecae5ad9b37619a38e699de00049e449f120b433c9f26a8beb05b990dfc54c9d?s=20&d=retro\"\
+        />\n                      Matt Prahl (mprahl)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n            </div>\n\
+        \            <h5><strong>Branches</strong></h5>\n            <div class=\"\
+        m-b-2\">\n              <div id=\"branch-master\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"\n          \
+        \          title=\"Default branch\"></span>\n                  <strong title=\"\
+        Currently viewing branch master\" data-toggle=\"tooltip\">master</strong>\n\
+        \                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                  <span class=\"oi text-muted\" data-glyph=\"star\"\n \
+        \                   title=\"Default branch\"></span>\n                </div>\n\
+        \              </div>\n\n              <div id=\"branch-2.9.x\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/2.9.x\"\n           \
+        \         title=\"2.9.x\" data-toggle=\"tooltip\">2.9.x\n                \
+        \  </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-alembic_changes\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/alembic_changes\"\n        \
+        \            title=\"alembic_changes\" data-toggle=\"tooltip\">alembic_changes\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-confirm_merge\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/confirm_merge\"\n   \
+        \                 title=\"confirm_merge\" data-toggle=\"tooltip\">confirm_merge\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-css_tables\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/css_tables\"\n      \
+        \              title=\"css_tables\" data-toggle=\"tooltip\">css_tables\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_del_project\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_del_project\"\n \
+        \                   title=\"fix_del_project\" data-toggle=\"tooltip\">fix_del_project\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_delete\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_delete\"\n      \
+        \              title=\"fix_delete\" data-toggle=\"tooltip\">fix_delete\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_diff_refs\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_diff_refs\"\n   \
+        \                 title=\"fix_diff_refs\" data-toggle=\"tooltip\">fix_diff_refs\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_doc\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_doc\"\n                \
+        \    title=\"fix_doc\" data-toggle=\"tooltip\">fix_doc\n                 \
+        \ </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_linking\" class=\"repoinfo-branchlistitem row\">\n\
+        \                <div class=\"branch_name col-md-8\">\n                  <span\
+        \ class=\"oi text-muted\" data-glyph=\"random\"></span>\n                \
+        \  <a class=\"\" href=\"/pagure/branch/fix_linking\"\n                   \
+        \ title=\"fix_linking\" data-toggle=\"tooltip\">fix_linking\n            \
+        \      </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_pygit_error\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_pygit_error\"\n        \
+        \            title=\"fix_pygit_error\" data-toggle=\"tooltip\">fix_pygit_error\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_reply_to\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_reply_to\"\n    \
+        \                title=\"fix_reply_to\" data-toggle=\"tooltip\">fix_reply_to\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_sse\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_sse\"\n                \
+        \    title=\"fix_sse\" data-toggle=\"tooltip\">fix_sse\n                 \
+        \ </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_sse_status\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_sse_status\"\n         \
+        \           title=\"fix_sse_status\" data-toggle=\"tooltip\">fix_sse_status\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_templates\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_templates\"\n   \
+        \                 title=\"fix_templates\" data-toggle=\"tooltip\">fix_templates\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fixes\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fixes\"\n                  \
+        \  title=\"fixes\" data-toggle=\"tooltip\">fixes\n                  </a>\n\
+        \n                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                </div>\n              </div>\n              <div id=\"\
+        branch-force_flag\" class=\"repoinfo-branchlistitem row\">\n             \
+        \   <div class=\"branch_name col-md-8\">\n                  <span class=\"\
+        oi text-muted\" data-glyph=\"random\"></span>\n                  <a class=\"\
+        \" href=\"/pagure/branch/force_flag\"\n                    title=\"force_flag\"\
+        \ data-toggle=\"tooltip\">force_flag\n                  </a>\n\n         \
+        \       </div>\n                <div class=\"branch_del col-md-4\">\n    \
+        \            </div>\n              </div>\n              <div id=\"branch-gpg_check\"\
+        \ class=\"repoinfo-branchlistitem row\">\n                <div class=\"branch_name\
+        \ col-md-8\">\n                  <span class=\"oi text-muted\" data-glyph=\"\
+        random\"></span>\n                  <a class=\"\" href=\"/pagure/branch/gpg_check\"\
+        \n                    title=\"gpg_check\" data-toggle=\"tooltip\">gpg_check\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-logging_milter\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/logging_milter\"\n  \
+        \                  title=\"logging_milter\" data-toggle=\"tooltip\">logging_milter\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-mirroring\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/mirroring\"\n              \
+        \      title=\"mirroring\" data-toggle=\"tooltip\">mirroring\n           \
+        \       </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-py3_work2\" class=\"repoinfo-branchlistitem row\">\n \
+        \               <div class=\"branch_name col-md-8\">\n                  <span\
+        \ class=\"oi text-muted\" data-glyph=\"random\"></span>\n                \
+        \  <a class=\"\" href=\"/pagure/branch/py3_work2\"\n                    title=\"\
+        py3_work2\" data-toggle=\"tooltip\">py3_work2\n                  </a>\n\n\
+        \                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                </div>\n              </div>\n              <div id=\"\
+        branch-rel_2.2.z\" class=\"repoinfo-branchlistitem row\">\n              \
+        \  <div class=\"branch_name col-md-8\">\n                  <span class=\"\
+        oi text-muted\" data-glyph=\"random\"></span>\n                  <a class=\"\
+        \" href=\"/pagure/branch/rel_2.2.z\"\n                    title=\"rel_2.2.z\"\
+        \ data-toggle=\"tooltip\">rel_2.2.z\n                  </a>\n\n          \
+        \      </div>\n                <div class=\"branch_del col-md-4\">\n     \
+        \           </div>\n              </div>\n              <div id=\"branch-rel_2.3.z\"\
+        \ class=\"repoinfo-branchlistitem row\">\n                <div class=\"branch_name\
+        \ col-md-8\">\n                  <span class=\"oi text-muted\" data-glyph=\"\
+        random\"></span>\n                  <a class=\"\" href=\"/pagure/branch/rel_2.3.z\"\
+        \n                    title=\"rel_2.3.z\" data-toggle=\"tooltip\">rel_2.3.z\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-scroll_metadata\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/scroll_metadata\"\n \
+        \                   title=\"scroll_metadata\" data-toggle=\"tooltip\">scroll_metadata\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-utf_search\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/utf_search\"\n      \
+        \              title=\"utf_search\" data-toggle=\"tooltip\">utf_search\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \         </div>\n            <h5><strong>Source GIT URLs</strong>       \
+        \           <span class=\"pull-xs-right\"><a data-toggle=\"collapse\" href=\"\
+        #moregiturls\"\n                  aria-expanded=\"false\" aria-controls=\"\
+        moregiturls\"\n                  id=\"more_gits\">more</a></span></h5>\n \
+        \           <div>\n              <div class=\"form-group\">\n            \
+        \    <div class=\"input-group input-group-sm\">\n                  <div class=\"\
+        input-group-addon\">GIT</div>\n                  <input class=\"form-control\"\
+        \ type=\"text\" value=\"https://pagure.io/pagure.git\" readonly>\n       \
+        \         </div>\n              </div>\n              <div class=\"collapse\"\
+        \ id=\"moregiturls\">\n                  <h5><strong>Docs GIT URLs</strong></h5>\n\
+        \                  <div class=\"form-group\">\n                    <div class=\"\
+        input-group input-group-sm\">\n                      <div class=\"input-group-addon\"\
+        >GIT</div>\n                      <input class=\"form-control\" type=\"text\"\
+        \ value=\"https://pagure.io/docs/pagure.git\" readonly>\n                \
+        \    </div>\n                  </div>\n              </div>\n            </div>\n\
+        \          </div>\n          <div class=\"repo_date\" title=\"2015-05-13 20:38:10.552662\"\
+        >\n              created 2 years ago\n          </div>\n        </div>\n\n\
+        \          <div class=\"card\">\n            <div class=\"card-header\">\n\
+        \              <strong>Recent Commits in\n                <span class=\"pr-toplabel\"\
+        >\n                  <span class=\"oi\" data-glyph=\"random\"></span>\n  \
+        \                master\n                </span>\n              </strong>\n\
+        \            </div>\n            <div class=\"card-block p-a-0\">\n      \
+        \        <div class=\"list-group list-group-flush\">\n                  <a\
+        \ href=\"/pagure/c/a324b4120738bb9765b59625f3e767f9b50d599f\" class=\"list-group-item\"\
+        >\n                    <div class=\"commitdate\" title=\"Sep 25 2017 12:50:46\"\
+        >\n                      <small>\n                        <strong>\n     \
+        \                     <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                          Pierre-Yves Chibon\n                       \
+        \ </strong>\n                        commited 2 days ago\n               \
+        \       </small>\n                    </div>\n                    <div><small>\n\
+        \                      Fix the unit-tests\n                    </small></div>\n\
+        \                  </a>\n                  <a href=\"/pagure/c/ef569a5160891b30cc2aa4d614b39f4a266d7ad7\"\
+        \ class=\"list-group-item\">\n                    <div class=\"commitdate\"\
+        \ title=\"Sep 21 2017 12:15:51\">\n                      <small>\n       \
+        \                 <strong>\n                          <img class=\"avatar\
+        \ circle\" src=\"https://seccdn.libravatar.org/avatar/342d1c36d5c2f6984933a1238581dfbe74a08bf09ccaf110793703f1e8067169?s=20&d=retro\"\
+        />\n                          Vivek Anand\n                        </strong>\n\
+        \                        commited 5 days ago\n                      </small>\n\
+        \                    </div>\n                    <div><small>\n          \
+        \            give project: generate gitolite acls after giving a project\n\
+        \                    </small></div>\n                  </a>\n            \
+        \      <a href=\"/pagure/c/16c5781815d4695fe89db3c5cec5191dc1b81c6f\" class=\"\
+        list-group-item\">\n                    <div class=\"commitdate\" title=\"\
+        Sep 21 2017 10:26:39\">\n                      <small>\n                 \
+        \       <strong>\n                          <img class=\"avatar circle\" src=\"\
+        https://seccdn.libravatar.org/avatar/342d1c36d5c2f6984933a1238581dfbe74a08bf09ccaf110793703f1e8067169?s=20&d=retro\"\
+        />\n                          Vivek Anand\n                        </strong>\n\
+        \                        commited 6 days ago\n                      </small>\n\
+        \                    </div>\n                    <div><small>\n          \
+        \            read only repo: Add unit test for update_read_only_mode method\n\
+        \                    </small></div>\n                  </a>\n            \
+        \  </div>\n              </div>\n            </div>\n          </div>\n  \
+        \    </div>\n    </div>\n\n\n</div>\n    </div>\n\n    <div class=\"footer\
+        \ p-t-1 p-b-1\">\n        <div class=\"container\">\n            <p class=\"\
+        text-muted credit\">\n         Copyright &copy; 2014-2017 Red Hat\n      \
+        \    <a href=\"https://pagure.io/pagure\">pagure</a> &mdash;\n          3.7.1\
+        \ &mdash; <a href=\"https://docs.pagure.org/pagure/usage/index.html\">Documentation</a>\n\
+        \            </p>\n            <p><a href=\"/ssh_info\">SSH Hostkey/Fingerprint</a></p>\n\
+        \        </div>\n    </div>\n\n    <script type=\"text/javascript\"\n    \
+        \    src=\"/static/vendor/jquery/jquery.min.js\">\n    </script>\n    <script\
+        \ type=\"text/javascript\"\n        src=\"/static/vendor/jquery-ui/jquery-ui.min.js\"\
+        >\n    </script>\n    <script type=\"text/javascript\"\n        src=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.js\"\
+        >\n    </script>\n    <script type=\"text/javascript\">\n$('[data-toggle=\"\
+        tooltip\"]').tooltip({placement : 'bottom'});\n    </script>\n\n<script type=\"\
+        text/javascript\">\n$(document).ready(function() {\n        var currentWatchStatusButton\
+        \ = $('#unwatch_button');\n    currentWatchStatusButton.prepend('<span class=\"\
+        oi\" data-glyph=\"circle-check\" style=\"padding-right:0.5em\"></span>');\n\
+        \    $('.watch-menu a').not(currentWatchStatusButton).css('padding-left',\
+        \ '2.85em');\n\n    $(\".watch-menu a\").click(function(){\n        var selectedValue\
+        \ = $(this).attr('id');\n        var watchProjectForm = $(\"#watch_project\"\
+        );\n        var action = watchProjectForm.attr('action');\n\n        if (selectedValue\
+        \ === \"watch_issues_button\") {\n            action = action.replace('/settings/0',\
+        \ '/settings/1');\n        } else if (selectedValue === \"watch_commits_button\"\
+        ) {\n            action = action.replace('/settings/0', '/settings/2');\n\
+        \        } else if (selectedValue === \"watch_issues_commits_button\") {\n\
+        \            action = action.replace('/settings/0', '/settings/3');\n    \
+        \    } else if (selectedValue === \"reset_button\") {\n            action\
+        \ = action.replace('/settings/0', '/settings/-1');\n        }\n\n        watchProjectForm.attr('action',\
+        \ action);\n        watchProjectForm.submit();\n    });\n});\n</script>\n\n\
+        <script type=\"text/javascript\">\n$(document).ready(function() {\n    $(\"\
+        .extra_gits\").hide();\n});\n\n$(function() {\n  $( \"#more_gits\" ).click(function()\
+        \ {\n      if ($( \"#more_gits\" ).html() == 'more') {\n        $( \"#more_gits\"\
+        \ ).html('less');\n      } else {\n        $( \"#more_gits\" ).html('more');\n\
+        \      }\n  });\n\n});\n</script>\n\n\n</body>\n</html>"}
+    headers:
+      Connection: [Keep-Alive]
+      Content-Length: ['31644']
+      Content-Type: [text/html; charset=utf-8]
+      Date: ['Wed, 27 Sep 2017 12:07:31 GMT']
+      Keep-Alive: ['timeout=5, max=100']
+      Server: [Apache/2.4.6 (Red Hat Enterprise Linux) OpenSSL/1.0.1e-fips mod_wsgi/3.4
+          Python/2.7.5]
+      Set-Cookie: ['pagure=eyJfcGVybWFuZW50Ijp0cnVlLCJjc3JmIjp7IiBiIjoiWVdVd01XWTBOVFE0Tnpoak16VXpOV0ZoT1RnME1EUTFNVEV5TUdabVpUUmhaalE1WWpWbFl3PT0ifX0.DK0mAw.6BBoDXAt_1zLVnTcZQa68xAzzTg;
+          Expires=Sat, 28-Oct-2017 12:07:31 GMT; Secure; HttpOnly; Path=/']
+      Strict-Transport-Security: [max-age=15768000; includeSubDomains; preload]
+    status: {code: 200, message: OK}
+version: 1
diff --git a/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidgets.test_get_widgets_private b/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidgets.test_get_widgets_private
new file mode 100644
index 0000000..0450f91
--- /dev/null
+++ b/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidgets.test_get_widgets_private
@@ -0,0 +1,450 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.12.5]
+    method: GET
+    uri: https://pagure.io/pagure
+  response:
+    body: {string: "<!DOCTYPE html>\n<html lang='en'>\n<head>\n    <meta http-equiv=\"\
+        Content-Type\" content=\"text/html; charset=UTF-8\"/>\n    <title>Overview\
+        \ - pagure - Pagure</title>\n    <link rel=\"shortcut icon\" type=\"image/vnd.microsoft.icon\"\
+        \n        href=\"/static/favicon.ico\"/>\n    <link href=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/pagure.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/vendor/open-iconic/css/open-iconic.min.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/fonts/fonts.css\"\
+        \n        rel=\"stylesheet\" type=\"text/css\" />\n    <link href=\"/static/vendor/hack_fonts/css/hack-extended.min.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <meta name=\"go-import\"\
+        \ content=\"pagure.io/pagure git https://pagure.io/pagure.git\">\n  </head>\n\
+        \  <body id=\"home\">\n  <!-- start masthead -->\n    <div class=\"masthead\"\
+        >\n      <div class=\"container\">\n        <div class=\"row\">\n        \
+        \  <div class=\"col-sm-3\">\n            <a href=\"/\">\n              <img\
+        \ height=40px src=\"/static/pagure-logo.png\"\n                alt=\"pagure\
+        \ Logo\" id=\"pagureLogo\"/>\n            </a>\n          </div>\n       \
+        \   <div class=\"col-sm-9\">\n            <div class=\"row\">\n\n  <nav class=\"\
+        navbar navbar-light p-t-0 p-b-0\">\n      <div class=\"container\">\n    \
+        \    <ul class=\"nav navbar-nav nav-underline pull-xs-right\">\n         \
+        \     <li class=\"nav-item p-l-1\">\n                <a class=\"nav-link btn\
+        \ btn-primary\" href=\"/login/?next=https://pagure.io/pagure\">Log In</a>\n\
+        \              </li>\n        </ul>\n      </div>\n    </nav>\n\n        \
+        \    </div>\n          </div>\n        </div>\n      </div>\n    </div><!--\
+        \ close masthead-->\n\n    <div class=\"bodycontent p-b-3\">\n\n\n<div class=\"\
+        repo-header p-t-1\">\n  <div class=\"container\">\n    <header>\n      <h2\
+        \ class=\"repo-name m-b-0\">\n<a href=\"/pagure\">pagure</a>\n        </h2>\n\
+        \        <div class=\"projectinfo m-t-1 m-b-1\">\nA git centered forge &nbsp;|\
+        \ &nbsp;<a class=\"inline\" href=\"https://pagure.io\">https://pagure.io</a>\
+        \        </div>\n\n    </header>\n    <ul class=\"nav nav-tabs nav-small\"\
+        >\n      <li class=\"nav-item\">\n        <a class=\"nav-link active\" href=\"\
+        /pagure\">\n            <span class=\"oi hidden-md-up\" data-glyph=\"home\"\
+        ></span>\n            <span class=\"hidden-sm-down\">Overview</span>\n   \
+        \     </a>\n      </li>\n        <li class=\"nav-item\" >\n          <a  class=\"\
+        nav-link\" href=\"/docs/pagure/\">\n              <span class=\"oi hidden-md-up\"\
+        \ data-glyph=\"book\"></span>\n              <span class=\"hidden-sm-down\"\
+        >Docs</span>\n          </a>\n        </li>\n\n        <li class=\"nav-item\"\
+        >\n          <a class=\"nav-link\" href=\"/pagure/commits/master\">\n    \
+        \          <span class=\"oi hidden-md-up\" data-glyph=\"spreadsheet\">\n \
+        \             </span><span class=\"hidden-sm-down\">Commits</span>\n     \
+        \       </a>\n        </li>\n\n        <li class=\"nav-item\">\n         \
+        \ <a class=\"nav-link\" href=\"/pagure/tree/master\">\n              <span\
+        \ class=\"oi hidden-md-up\" data-glyph=\"file\"></span>\n              <span\
+        \ class=\"hidden-sm-down\">Files</span>\n          </a>\n        </li>\n\n\
+        \        <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"\
+        /pagure/releases\">\n              <span class=\"oi hidden-md-up\" data-glyph=\"\
+        box\"></span>\n              <span class=\"hidden-sm-down\">Releases</span>\n\
+        \          </a>\n        </li>\n\n        <li class=\"nav-item\">\n      \
+        \      <a class=\"nav-link\" href=\"/pagure/issues\">\n                <span\
+        \ class=\"oi hidden-md-up\" data-glyph=\"warning\"></span>\n             \
+        \   <span class=\"hidden-sm-down\">Issues&nbsp;</span>\n                <span\
+        \ class=\"label label-default label-pill hidden-sm-down\">\n             \
+        \     340\n                </span>\n            </a>\n        </li>\n\n  \
+        \      <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"/pagure/pull-requests\"\
+        >\n              <span class=\"oi hidden-md-up\" data-glyph=\"task\"></span>\n\
+        \              <span class=\"hidden-sm-down\">Pull Requests&nbsp;</span>\n\
+        \              <span class=\"label label-default label-pill hidden-sm-down\"\
+        >\n                15\n              </span>\n          </a>\n        </li>\n\
+        \n        <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"\
+        /pagure/forks\">\n              <span class=\"oi hidden-md-up\" data-glyph=\"\
+        fork\"></span>\n              <span class=\"hidden-sm-down\">Forks&nbsp;</span>\n\
+        \              <span class=\"hidden-sm-down label label-default label-pill\"\
+        >\n                131\n              </span>\n            </a>\n        </li>\n\
+        \n\n    </ul>\n  </div>\n</div>\n\n<div class=\"container p-t-3\">\n  <div\
+        \ class=\"row\">\n        <div class=\"col-md-8\">\n            <section class=\"\
+        readme\">\n              <div class=\"document\">\n<div class=\"section\"\
+        >\n<h1>Pagure</h1>\n<table class=\"docutils field-list\">\n<colgroup><col>\n\
+        <col>\n</colgroup><tbody>\n<tr><th>Author:</th><td>Pierre-Yves Chibon &lt;<a\
+        \ href=\"mailto:pingou@pingoured.fr\">pingou@pingoured.fr</a>&gt;</td>\n</tr>\n\
+        </tbody>\n</table>\n<p>Pagure is a git-centered forge, python based using\
+        \ pygit2.</p>\n<p>With pagure you can host your project with its documentation,\
+        \ let your users\nreport issues or request enhancements using the ticketing\
+        \ system and build your\ncommunity of contributors by allowing them to fork\
+        \ your projects and contribute\nto it via the now-popular pull-request mechanism.</p>\n\
+        <p>Homepage: <a href=\"https://pagure.io/pagure\">https://pagure.io/pagure</a></p>\n\
+        <p>See it at work: <a href=\"https://pagure.io\">https://pagure.io</a></p>\n\
+        <p>Playground version: <a href=\"https://stg.pagure.io\">https://stg.pagure.io</a></p>\n\
+        </div>\n<div class=\"section\">\n<h1>Get it running</h1>\n<p>There are several\
+        \ options when it comes to a development environment. Vagrant\nwill provide\
+        \ you with a virtual machine which you can develop on, or you can\ninstall\
+        \ it directly on your host machine.</p>\n<div class=\"section\">\n<h2>Vagrant</h2>\n\
+        <p>For a more thorough introduction to Vagrant, see\n<a href=\"https://fedoraproject.org/wiki/Vagrant\"\
+        >https://fedoraproject.org/wiki/Vagrant</a>.</p>\n<p>An example Vagrantfile\
+        \ is provided as <code>Vagrantfile.example</code>. To use it,\njust copy it\
+        \ and install Vagrant:</p>\n<pre>$ cp dev/Vagrantfile.example Vagrantfile\n\
+        $ sudo dnf install ansible libvirt vagrant-libvirt vagrant-sshfs vagrant-hostmanager\n\
+        $ vagrant up\n</pre>\n<p>The default <code>Vagrantfile</code> forwards ports\
+        \ from the host to the guest,\nso you can interact with the application as\
+        \ if it were running on your\nhost machine.</p>\n<div class=\"note\">\n<p>Note</p>\n\
+        <p><code><span class=\"pre\">vagrant-hostmanager</span></code> will automatically\
+        \ maintain /etc/hosts for you so you\ncan access the development environment\
+        \ from the host using its hostname, which\nby default is <code><span class=\"\
+        pre\">pagure-dev.example.com</span></code>. You can choose not to use this\n\
+        functionality by simply not installing the <code><span class=\"pre\">vagrant-hostmanager</span></code>\
+        \ plugin, but\nif you want Pagure to provide valid URLs in the UI for git\
+        \ repositories, you\nwill need to adjust Pagure's configuration found in ~/pagure.cfg\
+        \ on the guest.</p>\n</div>\n</div>\n<div class=\"section\">\n<h2>Manually</h2>\n\
+        <ul>\n<li><p>Install the needed system libraries:</p>\n<pre>sudo dnf install\
+        \ git python2-virtualenv libgit2-devel \\\n                 libjpeg-devel\
+        \ gcc libffi-devel redhat-rpm-config\n</pre>\n<div class=\"note\">\n<p>Note</p>\n\
+        <p>Do note the version of libgit2 that you install, for example\nin <code><span\
+        \ class=\"pre\">libgit2-0.23.4-1</span></code> you need to keep in mind the\
+        \ <code>0.23</code></p>\n</div>\n<div class=\"note\">\n<p>Note</p>\n<p>On\
+        \ Fedora 23 and earlier or on RHEL and derivative (CentOS,\nScientific Linux)\
+        \ the package <cite>python2-virtualenv</cite> is named\n<cite>python-virtualenv</cite></p>\n\
+        </div>\n</li>\n<li><p>Retrieve the sources:</p>\n<pre>git clone https://pagure.io/pagure.git\n\
+        cd pagure\n</pre>\n</li>\n<li><p>Install dependencies</p>\n<ul>\n<li><p>create\
+        \ the virtualenv:</p>\n<pre>virtualenv pagure_env\nsource ./pagure_env/bin/activate\n\
+        </pre>\n</li>\n<li><p>Install the correct version of pygit2:</p>\n<pre>pip\
+        \ install pygit2==&lt;version of libgit2 found&gt;.*\n</pre>\n<p>So in our\
+        \ example:</p>\n<pre>pip install pygit2==0.23.*\n</pre>\n</li>\n<li><p>Install\
+        \ the rest of the dependencies:</p>\n<pre>pip install -r requirements.txt\n\
+        </pre>\n</li>\n</ul>\n</li>\n<li><p>Create the folder that will receive the\
+        \ projects, forks, docs, requests and\ntickets' git repo:</p>\n<pre>mkdir\
+        \ -p lcl/{repos,docs,forks,tickets,requests,remotes,attachments,releases}\n\
+        </pre>\n</li>\n<li><p>Create the inital database scheme:</p>\n<pre>python\
+        \ createdb.py\n</pre>\n</li>\n<li><p>Start a worker, in one terminal:</p>\n\
+        <pre>./runworker.py\n</pre>\n</li>\n<li><p>Run the application, in another\
+        \ terminal:</p>\n<pre>./runserver.py\n</pre>\n</li>\n<li><p>To get some profiling\
+        \ information you can also run it as:</p>\n<pre>./runserver.py --profile\n\
+        </pre>\n</li>\n</ul>\n<p>This will launch the application at <a href=\"http://127.0.0.1:5000\"\
+        >http://127.0.0.1:5000</a></p>\n<ul>\n<li><p>To run unit-tests on pagure</p>\n\
+        <ul>\n<li><p>Install the dependencies:</p>\n<pre>pip install -r tests_requirements.txt\n\
+        </pre>\n</li>\n<li><p>Run it:</p>\n<pre>./runtests.sh\n</pre>\n<div class=\"\
+        note\">\n<p>Note</p>\n<p>While testing for worker tasks, pagure uses celery\
+        \ in /usr/bin/\nCelery then looks for eventlet (which we use for testing only)\
+        \ at\nsystem level and not in virtualenv. You will need to install eventlet\n\
+        outside of your virtualenv if you are using one.</p>\n</div>\n</li>\n</ul>\n\
+        </li>\n</ul>\n</div>\n</div>\n</div>\n\n            </section>\n        </div>\n\
+        \      <div class=\"col-md-4\">\n        <div class=\"card\">\n          <div\
+        \ class=\"card-block\">\n            <h5><strong>Contributors</strong></h5>\n\
+        \            <div class=\"m-b-2\">\n              <div>\n                <a\
+        \ href=\"/user/pingou\">\n                  <img class=\"avatar circle\" src=\"\
+        https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                  Pierre-YvesChibon (pingou)\n                </a>\n \
+        \               -  main admin\n              </div>\n                  <div>\n\
+        \                    <a href=\"/user/ryanlerch\">\n                      <img\
+        \ class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/de5bf8d06663adb3bb1b8d49ccab259828fad7dddeb233b073d0c447d79b4c14?s=20&d=retro\"\
+        />\n                      Ryan Lerch (ryanlerch)\n                    </a>\n\
+        \                    - admin\n                  </div>\n                 \
+        \ <div>\n                    <a href=\"/user/puiterwijk\">\n             \
+        \         <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/85f8b5f58b9808429ad69ba3722d64da01ce850635e348e838dfdc053e1140e3?s=20&d=retro\"\
+        />\n                      Patrick &#34;\u30DE\u30EB\u30BF\u30A4\u30F3\u30A2\
+        \u30F3\u30C9\u30EC\u30A2\u30B9&#34; Uiterwijk (puiterwijk)\n             \
+        \       </a>\n                    - commit\n                  </div>\n   \
+        \               <div>\n                    <a href=\"/user/vivekanand1101\"\
+        >\n                      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/342d1c36d5c2f6984933a1238581dfbe74a08bf09ccaf110793703f1e8067169?s=20&d=retro\"\
+        />\n                      Vivek Anand (vivekanand1101)\n                 \
+        \   </a>\n                    - ticket\n                  </div>\n       \
+        \           <div>\n                    <a href=\"/user/farhaan\">\n      \
+        \                <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/b681ddfaffa293b03c57e5f0ed90eb1e06a8f748f4dccdccef8cdd301c66c9ae?s=20&d=retro\"\
+        />\n                      Farhaan Bukhsh (farhaan)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/cverna\">\n                \
+        \      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/c7844b93c43c862adaa29920c09bdfb91efed57a843203e5d0b72673e664c003?s=20&d=retro\"\
+        />\n                      Clement Verna (cverna)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/jcline\">\n                \
+        \      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/1a108f46a960aa35efcabd0b779cd59b8c3aba2927213fe63373921e1aa3fac4?s=20&d=retro\"\
+        />\n                      Jeremy Cline (jcline)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/lslebodn\">\n              \
+        \        <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/8c6fcfe0eec099f1f01f5c237935bb5c97f2063a5c1068e57c5815e0bfecbf70?s=20&d=retro\"\
+        />\n                      Lukas Slebodnik (lslebodn)\n                   \
+        \ </a>\n                    - ticket\n                  </div>\n         \
+        \         <div>\n                    <a href=\"/user/mprahl\">\n         \
+        \             <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/ecae5ad9b37619a38e699de00049e449f120b433c9f26a8beb05b990dfc54c9d?s=20&d=retro\"\
+        />\n                      Matt Prahl (mprahl)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n            </div>\n\
+        \            <h5><strong>Branches</strong></h5>\n            <div class=\"\
+        m-b-2\">\n              <div id=\"branch-master\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"\n          \
+        \          title=\"Default branch\"></span>\n                  <strong title=\"\
+        Currently viewing branch master\" data-toggle=\"tooltip\">master</strong>\n\
+        \                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                  <span class=\"oi text-muted\" data-glyph=\"star\"\n \
+        \                   title=\"Default branch\"></span>\n                </div>\n\
+        \              </div>\n\n              <div id=\"branch-2.9.x\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/2.9.x\"\n           \
+        \         title=\"2.9.x\" data-toggle=\"tooltip\">2.9.x\n                \
+        \  </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-alembic_changes\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/alembic_changes\"\n        \
+        \            title=\"alembic_changes\" data-toggle=\"tooltip\">alembic_changes\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-confirm_merge\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/confirm_merge\"\n   \
+        \                 title=\"confirm_merge\" data-toggle=\"tooltip\">confirm_merge\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-css_tables\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/css_tables\"\n      \
+        \              title=\"css_tables\" data-toggle=\"tooltip\">css_tables\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_del_project\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_del_project\"\n \
+        \                   title=\"fix_del_project\" data-toggle=\"tooltip\">fix_del_project\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_delete\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_delete\"\n      \
+        \              title=\"fix_delete\" data-toggle=\"tooltip\">fix_delete\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_diff_refs\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_diff_refs\"\n   \
+        \                 title=\"fix_diff_refs\" data-toggle=\"tooltip\">fix_diff_refs\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_doc\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_doc\"\n                \
+        \    title=\"fix_doc\" data-toggle=\"tooltip\">fix_doc\n                 \
+        \ </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_linking\" class=\"repoinfo-branchlistitem row\">\n\
+        \                <div class=\"branch_name col-md-8\">\n                  <span\
+        \ class=\"oi text-muted\" data-glyph=\"random\"></span>\n                \
+        \  <a class=\"\" href=\"/pagure/branch/fix_linking\"\n                   \
+        \ title=\"fix_linking\" data-toggle=\"tooltip\">fix_linking\n            \
+        \      </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_pygit_error\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_pygit_error\"\n        \
+        \            title=\"fix_pygit_error\" data-toggle=\"tooltip\">fix_pygit_error\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_reply_to\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_reply_to\"\n    \
+        \                title=\"fix_reply_to\" data-toggle=\"tooltip\">fix_reply_to\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_sse\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_sse\"\n                \
+        \    title=\"fix_sse\" data-toggle=\"tooltip\">fix_sse\n                 \
+        \ </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_sse_status\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_sse_status\"\n         \
+        \           title=\"fix_sse_status\" data-toggle=\"tooltip\">fix_sse_status\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_templates\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_templates\"\n   \
+        \                 title=\"fix_templates\" data-toggle=\"tooltip\">fix_templates\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fixes\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fixes\"\n                  \
+        \  title=\"fixes\" data-toggle=\"tooltip\">fixes\n                  </a>\n\
+        \n                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                </div>\n              </div>\n              <div id=\"\
+        branch-force_flag\" class=\"repoinfo-branchlistitem row\">\n             \
+        \   <div class=\"branch_name col-md-8\">\n                  <span class=\"\
+        oi text-muted\" data-glyph=\"random\"></span>\n                  <a class=\"\
+        \" href=\"/pagure/branch/force_flag\"\n                    title=\"force_flag\"\
+        \ data-toggle=\"tooltip\">force_flag\n                  </a>\n\n         \
+        \       </div>\n                <div class=\"branch_del col-md-4\">\n    \
+        \            </div>\n              </div>\n              <div id=\"branch-gpg_check\"\
+        \ class=\"repoinfo-branchlistitem row\">\n                <div class=\"branch_name\
+        \ col-md-8\">\n                  <span class=\"oi text-muted\" data-glyph=\"\
+        random\"></span>\n                  <a class=\"\" href=\"/pagure/branch/gpg_check\"\
+        \n                    title=\"gpg_check\" data-toggle=\"tooltip\">gpg_check\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-logging_milter\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/logging_milter\"\n  \
+        \                  title=\"logging_milter\" data-toggle=\"tooltip\">logging_milter\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-mirroring\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/mirroring\"\n              \
+        \      title=\"mirroring\" data-toggle=\"tooltip\">mirroring\n           \
+        \       </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-py3_work2\" class=\"repoinfo-branchlistitem row\">\n \
+        \               <div class=\"branch_name col-md-8\">\n                  <span\
+        \ class=\"oi text-muted\" data-glyph=\"random\"></span>\n                \
+        \  <a class=\"\" href=\"/pagure/branch/py3_work2\"\n                    title=\"\
+        py3_work2\" data-toggle=\"tooltip\">py3_work2\n                  </a>\n\n\
+        \                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                </div>\n              </div>\n              <div id=\"\
+        branch-rel_2.2.z\" class=\"repoinfo-branchlistitem row\">\n              \
+        \  <div class=\"branch_name col-md-8\">\n                  <span class=\"\
+        oi text-muted\" data-glyph=\"random\"></span>\n                  <a class=\"\
+        \" href=\"/pagure/branch/rel_2.2.z\"\n                    title=\"rel_2.2.z\"\
+        \ data-toggle=\"tooltip\">rel_2.2.z\n                  </a>\n\n          \
+        \      </div>\n                <div class=\"branch_del col-md-4\">\n     \
+        \           </div>\n              </div>\n              <div id=\"branch-rel_2.3.z\"\
+        \ class=\"repoinfo-branchlistitem row\">\n                <div class=\"branch_name\
+        \ col-md-8\">\n                  <span class=\"oi text-muted\" data-glyph=\"\
+        random\"></span>\n                  <a class=\"\" href=\"/pagure/branch/rel_2.3.z\"\
+        \n                    title=\"rel_2.3.z\" data-toggle=\"tooltip\">rel_2.3.z\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-scroll_metadata\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/scroll_metadata\"\n \
+        \                   title=\"scroll_metadata\" data-toggle=\"tooltip\">scroll_metadata\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-utf_search\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/utf_search\"\n      \
+        \              title=\"utf_search\" data-toggle=\"tooltip\">utf_search\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \         </div>\n            <h5><strong>Source GIT URLs</strong>       \
+        \           <span class=\"pull-xs-right\"><a data-toggle=\"collapse\" href=\"\
+        #moregiturls\"\n                  aria-expanded=\"false\" aria-controls=\"\
+        moregiturls\"\n                  id=\"more_gits\">more</a></span></h5>\n \
+        \           <div>\n              <div class=\"form-group\">\n            \
+        \    <div class=\"input-group input-group-sm\">\n                  <div class=\"\
+        input-group-addon\">GIT</div>\n                  <input class=\"form-control\"\
+        \ type=\"text\" value=\"https://pagure.io/pagure.git\" readonly>\n       \
+        \         </div>\n              </div>\n              <div class=\"collapse\"\
+        \ id=\"moregiturls\">\n                  <h5><strong>Docs GIT URLs</strong></h5>\n\
+        \                  <div class=\"form-group\">\n                    <div class=\"\
+        input-group input-group-sm\">\n                      <div class=\"input-group-addon\"\
+        >GIT</div>\n                      <input class=\"form-control\" type=\"text\"\
+        \ value=\"https://pagure.io/docs/pagure.git\" readonly>\n                \
+        \    </div>\n                  </div>\n              </div>\n            </div>\n\
+        \          </div>\n          <div class=\"repo_date\" title=\"2015-05-13 20:38:10.552662\"\
+        >\n              created 2 years ago\n          </div>\n        </div>\n\n\
+        \          <div class=\"card\">\n            <div class=\"card-header\">\n\
+        \              <strong>Recent Commits in\n                <span class=\"pr-toplabel\"\
+        >\n                  <span class=\"oi\" data-glyph=\"random\"></span>\n  \
+        \                master\n                </span>\n              </strong>\n\
+        \            </div>\n            <div class=\"card-block p-a-0\">\n      \
+        \        <div class=\"list-group list-group-flush\">\n                  <a\
+        \ href=\"/pagure/c/a324b4120738bb9765b59625f3e767f9b50d599f\" class=\"list-group-item\"\
+        >\n                    <div class=\"commitdate\" title=\"Sep 25 2017 12:50:46\"\
+        >\n                      <small>\n                        <strong>\n     \
+        \                     <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                          Pierre-Yves Chibon\n                       \
+        \ </strong>\n                        commited 2 days ago\n               \
+        \       </small>\n                    </div>\n                    <div><small>\n\
+        \                      Fix the unit-tests\n                    </small></div>\n\
+        \                  </a>\n                  <a href=\"/pagure/c/ef569a5160891b30cc2aa4d614b39f4a266d7ad7\"\
+        \ class=\"list-group-item\">\n                    <div class=\"commitdate\"\
+        \ title=\"Sep 21 2017 12:15:51\">\n                      <small>\n       \
+        \                 <strong>\n                          <img class=\"avatar\
+        \ circle\" src=\"https://seccdn.libravatar.org/avatar/342d1c36d5c2f6984933a1238581dfbe74a08bf09ccaf110793703f1e8067169?s=20&d=retro\"\
+        />\n                          Vivek Anand\n                        </strong>\n\
+        \                        commited 5 days ago\n                      </small>\n\
+        \                    </div>\n                    <div><small>\n          \
+        \            give project: generate gitolite acls after giving a project\n\
+        \                    </small></div>\n                  </a>\n            \
+        \      <a href=\"/pagure/c/16c5781815d4695fe89db3c5cec5191dc1b81c6f\" class=\"\
+        list-group-item\">\n                    <div class=\"commitdate\" title=\"\
+        Sep 21 2017 10:26:39\">\n                      <small>\n                 \
+        \       <strong>\n                          <img class=\"avatar circle\" src=\"\
+        https://seccdn.libravatar.org/avatar/342d1c36d5c2f6984933a1238581dfbe74a08bf09ccaf110793703f1e8067169?s=20&d=retro\"\
+        />\n                          Vivek Anand\n                        </strong>\n\
+        \                        commited 6 days ago\n                      </small>\n\
+        \                    </div>\n                    <div><small>\n          \
+        \            read only repo: Add unit test for update_read_only_mode method\n\
+        \                    </small></div>\n                  </a>\n            \
+        \  </div>\n              </div>\n            </div>\n          </div>\n  \
+        \    </div>\n    </div>\n\n\n</div>\n    </div>\n\n    <div class=\"footer\
+        \ p-t-1 p-b-1\">\n        <div class=\"container\">\n            <p class=\"\
+        text-muted credit\">\n         Copyright &copy; 2014-2017 Red Hat\n      \
+        \    <a href=\"https://pagure.io/pagure\">pagure</a> &mdash;\n          3.7.1\
+        \ &mdash; <a href=\"https://docs.pagure.org/pagure/usage/index.html\">Documentation</a>\n\
+        \            </p>\n            <p><a href=\"/ssh_info\">SSH Hostkey/Fingerprint</a></p>\n\
+        \        </div>\n    </div>\n\n    <script type=\"text/javascript\"\n    \
+        \    src=\"/static/vendor/jquery/jquery.min.js\">\n    </script>\n    <script\
+        \ type=\"text/javascript\"\n        src=\"/static/vendor/jquery-ui/jquery-ui.min.js\"\
+        >\n    </script>\n    <script type=\"text/javascript\"\n        src=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.js\"\
+        >\n    </script>\n    <script type=\"text/javascript\">\n$('[data-toggle=\"\
+        tooltip\"]').tooltip({placement : 'bottom'});\n    </script>\n\n<script type=\"\
+        text/javascript\">\n$(document).ready(function() {\n        var currentWatchStatusButton\
+        \ = $('#unwatch_button');\n    currentWatchStatusButton.prepend('<span class=\"\
+        oi\" data-glyph=\"circle-check\" style=\"padding-right:0.5em\"></span>');\n\
+        \    $('.watch-menu a').not(currentWatchStatusButton).css('padding-left',\
+        \ '2.85em');\n\n    $(\".watch-menu a\").click(function(){\n        var selectedValue\
+        \ = $(this).attr('id');\n        var watchProjectForm = $(\"#watch_project\"\
+        );\n        var action = watchProjectForm.attr('action');\n\n        if (selectedValue\
+        \ === \"watch_issues_button\") {\n            action = action.replace('/settings/0',\
+        \ '/settings/1');\n        } else if (selectedValue === \"watch_commits_button\"\
+        ) {\n            action = action.replace('/settings/0', '/settings/2');\n\
+        \        } else if (selectedValue === \"watch_issues_commits_button\") {\n\
+        \            action = action.replace('/settings/0', '/settings/3');\n    \
+        \    } else if (selectedValue === \"reset_button\") {\n            action\
+        \ = action.replace('/settings/0', '/settings/-1');\n        }\n\n        watchProjectForm.attr('action',\
+        \ action);\n        watchProjectForm.submit();\n    });\n});\n</script>\n\n\
+        <script type=\"text/javascript\">\n$(document).ready(function() {\n    $(\"\
+        .extra_gits\").hide();\n});\n\n$(function() {\n  $( \"#more_gits\" ).click(function()\
+        \ {\n      if ($( \"#more_gits\" ).html() == 'more') {\n        $( \"#more_gits\"\
+        \ ).html('less');\n      } else {\n        $( \"#more_gits\" ).html('more');\n\
+        \      }\n  });\n\n});\n</script>\n\n\n</body>\n</html>"}
+    headers:
+      Connection: [Keep-Alive]
+      Content-Length: ['31644']
+      Content-Type: [text/html; charset=utf-8]
+      Date: ['Wed, 27 Sep 2017 12:07:33 GMT']
+      Keep-Alive: ['timeout=5, max=100']
+      Server: [Apache/2.4.6 (Red Hat Enterprise Linux) OpenSSL/1.0.1e-fips mod_wsgi/3.4
+          Python/2.7.5]
+      Set-Cookie: ['pagure=eyJfcGVybWFuZW50Ijp0cnVlLCJjc3JmIjp7IiBiIjoiT1daa01tWTBOak01WkRCak5ESXlaR1EzTmprNE5qSmpPVGRsTkRSaFlUazJNV0UxWXpZMk5RPT0ifX0.DK0mBQ.zItnVKliuOGIIH4LSzZAPjrw0HY;
+          Expires=Sat, 28-Oct-2017 12:07:33 GMT; Secure; HttpOnly; Path=/']
+      Strict-Transport-Security: [max-age=15768000; includeSubDomains; preload]
+    status: {code: 200, message: OK}
+version: 1
diff --git a/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidgets.test_post_valid_widget_name_no_config b/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidgets.test_post_valid_widget_name_no_config
new file mode 100644
index 0000000..cad3a65
--- /dev/null
+++ b/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidgets.test_post_valid_widget_name_no_config
@@ -0,0 +1,450 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.12.5]
+    method: GET
+    uri: https://pagure.io/pagure
+  response:
+    body: {string: "<!DOCTYPE html>\n<html lang='en'>\n<head>\n    <meta http-equiv=\"\
+        Content-Type\" content=\"text/html; charset=UTF-8\"/>\n    <title>Overview\
+        \ - pagure - Pagure</title>\n    <link rel=\"shortcut icon\" type=\"image/vnd.microsoft.icon\"\
+        \n        href=\"/static/favicon.ico\"/>\n    <link href=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/pagure.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/vendor/open-iconic/css/open-iconic.min.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/fonts/fonts.css\"\
+        \n        rel=\"stylesheet\" type=\"text/css\" />\n    <link href=\"/static/vendor/hack_fonts/css/hack-extended.min.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <meta name=\"go-import\"\
+        \ content=\"pagure.io/pagure git https://pagure.io/pagure.git\">\n  </head>\n\
+        \  <body id=\"home\">\n  <!-- start masthead -->\n    <div class=\"masthead\"\
+        >\n      <div class=\"container\">\n        <div class=\"row\">\n        \
+        \  <div class=\"col-sm-3\">\n            <a href=\"/\">\n              <img\
+        \ height=40px src=\"/static/pagure-logo.png\"\n                alt=\"pagure\
+        \ Logo\" id=\"pagureLogo\"/>\n            </a>\n          </div>\n       \
+        \   <div class=\"col-sm-9\">\n            <div class=\"row\">\n\n  <nav class=\"\
+        navbar navbar-light p-t-0 p-b-0\">\n      <div class=\"container\">\n    \
+        \    <ul class=\"nav navbar-nav nav-underline pull-xs-right\">\n         \
+        \     <li class=\"nav-item p-l-1\">\n                <a class=\"nav-link btn\
+        \ btn-primary\" href=\"/login/?next=https://pagure.io/pagure\">Log In</a>\n\
+        \              </li>\n        </ul>\n      </div>\n    </nav>\n\n        \
+        \    </div>\n          </div>\n        </div>\n      </div>\n    </div><!--\
+        \ close masthead-->\n\n    <div class=\"bodycontent p-b-3\">\n\n\n<div class=\"\
+        repo-header p-t-1\">\n  <div class=\"container\">\n    <header>\n      <h2\
+        \ class=\"repo-name m-b-0\">\n<a href=\"/pagure\">pagure</a>\n        </h2>\n\
+        \        <div class=\"projectinfo m-t-1 m-b-1\">\nA git centered forge &nbsp;|\
+        \ &nbsp;<a class=\"inline\" href=\"https://pagure.io\">https://pagure.io</a>\
+        \        </div>\n\n    </header>\n    <ul class=\"nav nav-tabs nav-small\"\
+        >\n      <li class=\"nav-item\">\n        <a class=\"nav-link active\" href=\"\
+        /pagure\">\n            <span class=\"oi hidden-md-up\" data-glyph=\"home\"\
+        ></span>\n            <span class=\"hidden-sm-down\">Overview</span>\n   \
+        \     </a>\n      </li>\n        <li class=\"nav-item\" >\n          <a  class=\"\
+        nav-link\" href=\"/docs/pagure/\">\n              <span class=\"oi hidden-md-up\"\
+        \ data-glyph=\"book\"></span>\n              <span class=\"hidden-sm-down\"\
+        >Docs</span>\n          </a>\n        </li>\n\n        <li class=\"nav-item\"\
+        >\n          <a class=\"nav-link\" href=\"/pagure/commits/master\">\n    \
+        \          <span class=\"oi hidden-md-up\" data-glyph=\"spreadsheet\">\n \
+        \             </span><span class=\"hidden-sm-down\">Commits</span>\n     \
+        \       </a>\n        </li>\n\n        <li class=\"nav-item\">\n         \
+        \ <a class=\"nav-link\" href=\"/pagure/tree/master\">\n              <span\
+        \ class=\"oi hidden-md-up\" data-glyph=\"file\"></span>\n              <span\
+        \ class=\"hidden-sm-down\">Files</span>\n          </a>\n        </li>\n\n\
+        \        <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"\
+        /pagure/releases\">\n              <span class=\"oi hidden-md-up\" data-glyph=\"\
+        box\"></span>\n              <span class=\"hidden-sm-down\">Releases</span>\n\
+        \          </a>\n        </li>\n\n        <li class=\"nav-item\">\n      \
+        \      <a class=\"nav-link\" href=\"/pagure/issues\">\n                <span\
+        \ class=\"oi hidden-md-up\" data-glyph=\"warning\"></span>\n             \
+        \   <span class=\"hidden-sm-down\">Issues&nbsp;</span>\n                <span\
+        \ class=\"label label-default label-pill hidden-sm-down\">\n             \
+        \     339\n                </span>\n            </a>\n        </li>\n\n  \
+        \      <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"/pagure/pull-requests\"\
+        >\n              <span class=\"oi hidden-md-up\" data-glyph=\"task\"></span>\n\
+        \              <span class=\"hidden-sm-down\">Pull Requests&nbsp;</span>\n\
+        \              <span class=\"label label-default label-pill hidden-sm-down\"\
+        >\n                16\n              </span>\n          </a>\n        </li>\n\
+        \n        <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"\
+        /pagure/forks\">\n              <span class=\"oi hidden-md-up\" data-glyph=\"\
+        fork\"></span>\n              <span class=\"hidden-sm-down\">Forks&nbsp;</span>\n\
+        \              <span class=\"hidden-sm-down label label-default label-pill\"\
+        >\n                131\n              </span>\n            </a>\n        </li>\n\
+        \n\n    </ul>\n  </div>\n</div>\n\n<div class=\"container p-t-3\">\n  <div\
+        \ class=\"row\">\n        <div class=\"col-md-8\">\n            <section class=\"\
+        readme\">\n              <div class=\"document\">\n<div class=\"section\"\
+        >\n<h1>Pagure</h1>\n<table class=\"docutils field-list\">\n<colgroup><col>\n\
+        <col>\n</colgroup><tbody>\n<tr><th>Author:</th><td>Pierre-Yves Chibon &lt;<a\
+        \ href=\"mailto:pingou@pingoured.fr\">pingou@pingoured.fr</a>&gt;</td>\n</tr>\n\
+        </tbody>\n</table>\n<p>Pagure is a git-centered forge, python based using\
+        \ pygit2.</p>\n<p>With pagure you can host your project with its documentation,\
+        \ let your users\nreport issues or request enhancements using the ticketing\
+        \ system and build your\ncommunity of contributors by allowing them to fork\
+        \ your projects and contribute\nto it via the now-popular pull-request mechanism.</p>\n\
+        <p>Homepage: <a href=\"https://pagure.io/pagure\">https://pagure.io/pagure</a></p>\n\
+        <p>See it at work: <a href=\"https://pagure.io\">https://pagure.io</a></p>\n\
+        <p>Playground version: <a href=\"https://stg.pagure.io\">https://stg.pagure.io</a></p>\n\
+        </div>\n<div class=\"section\">\n<h1>Get it running</h1>\n<p>There are several\
+        \ options when it comes to a development environment. Vagrant\nwill provide\
+        \ you with a virtual machine which you can develop on, or you can\ninstall\
+        \ it directly on your host machine.</p>\n<div class=\"section\">\n<h2>Vagrant</h2>\n\
+        <p>For a more thorough introduction to Vagrant, see\n<a href=\"https://fedoraproject.org/wiki/Vagrant\"\
+        >https://fedoraproject.org/wiki/Vagrant</a>.</p>\n<p>An example Vagrantfile\
+        \ is provided as <code>Vagrantfile.example</code>. To use it,\njust copy it\
+        \ and install Vagrant:</p>\n<pre>$ cp dev/Vagrantfile.example Vagrantfile\n\
+        $ sudo dnf install ansible libvirt vagrant-libvirt vagrant-sshfs vagrant-hostmanager\n\
+        $ vagrant up\n</pre>\n<p>The default <code>Vagrantfile</code> forwards ports\
+        \ from the host to the guest,\nso you can interact with the application as\
+        \ if it were running on your\nhost machine.</p>\n<div class=\"note\">\n<p>Note</p>\n\
+        <p><code><span class=\"pre\">vagrant-hostmanager</span></code> will automatically\
+        \ maintain /etc/hosts for you so you\ncan access the development environment\
+        \ from the host using its hostname, which\nby default is <code><span class=\"\
+        pre\">pagure-dev.example.com</span></code>. You can choose not to use this\n\
+        functionality by simply not installing the <code><span class=\"pre\">vagrant-hostmanager</span></code>\
+        \ plugin, but\nif you want Pagure to provide valid URLs in the UI for git\
+        \ repositories, you\nwill need to adjust Pagure's configuration found in ~/pagure.cfg\
+        \ on the guest.</p>\n</div>\n</div>\n<div class=\"section\">\n<h2>Manually</h2>\n\
+        <ul>\n<li><p>Install the needed system libraries:</p>\n<pre>sudo dnf install\
+        \ git python2-virtualenv libgit2-devel \\\n                 libjpeg-devel\
+        \ gcc libffi-devel redhat-rpm-config\n</pre>\n<div class=\"note\">\n<p>Note</p>\n\
+        <p>Do note the version of libgit2 that you install, for example\nin <code><span\
+        \ class=\"pre\">libgit2-0.23.4-1</span></code> you need to keep in mind the\
+        \ <code>0.23</code></p>\n</div>\n<div class=\"note\">\n<p>Note</p>\n<p>On\
+        \ Fedora 23 and earlier or on RHEL and derivative (CentOS,\nScientific Linux)\
+        \ the package <cite>python2-virtualenv</cite> is named\n<cite>python-virtualenv</cite></p>\n\
+        </div>\n</li>\n<li><p>Retrieve the sources:</p>\n<pre>git clone https://pagure.io/pagure.git\n\
+        cd pagure\n</pre>\n</li>\n<li><p>Install dependencies</p>\n<ul>\n<li><p>create\
+        \ the virtualenv:</p>\n<pre>virtualenv pagure_env\nsource ./pagure_env/bin/activate\n\
+        </pre>\n</li>\n<li><p>Install the correct version of pygit2:</p>\n<pre>pip\
+        \ install pygit2==&lt;version of libgit2 found&gt;.*\n</pre>\n<p>So in our\
+        \ example:</p>\n<pre>pip install pygit2==0.23.*\n</pre>\n</li>\n<li><p>Install\
+        \ the rest of the dependencies:</p>\n<pre>pip install -r requirements.txt\n\
+        </pre>\n</li>\n</ul>\n</li>\n<li><p>Create the folder that will receive the\
+        \ projects, forks, docs, requests and\ntickets' git repo:</p>\n<pre>mkdir\
+        \ -p lcl/{repos,docs,forks,tickets,requests,remotes,attachments,releases}\n\
+        </pre>\n</li>\n<li><p>Create the inital database scheme:</p>\n<pre>python\
+        \ createdb.py\n</pre>\n</li>\n<li><p>Start a worker, in one terminal:</p>\n\
+        <pre>./runworker.py\n</pre>\n</li>\n<li><p>Run the application, in another\
+        \ terminal:</p>\n<pre>./runserver.py\n</pre>\n</li>\n<li><p>To get some profiling\
+        \ information you can also run it as:</p>\n<pre>./runserver.py --profile\n\
+        </pre>\n</li>\n</ul>\n<p>This will launch the application at <a href=\"http://127.0.0.1:5000\"\
+        >http://127.0.0.1:5000</a></p>\n<ul>\n<li><p>To run unit-tests on pagure</p>\n\
+        <ul>\n<li><p>Install the dependencies:</p>\n<pre>pip install -r tests_requirements.txt\n\
+        </pre>\n</li>\n<li><p>Run it:</p>\n<pre>./runtests.sh\n</pre>\n<div class=\"\
+        note\">\n<p>Note</p>\n<p>While testing for worker tasks, pagure uses celery\
+        \ in /usr/bin/\nCelery then looks for eventlet (which we use for testing only)\
+        \ at\nsystem level and not in virtualenv. You will need to install eventlet\n\
+        outside of your virtualenv if you are using one.</p>\n</div>\n</li>\n</ul>\n\
+        </li>\n</ul>\n</div>\n</div>\n</div>\n\n            </section>\n        </div>\n\
+        \      <div class=\"col-md-4\">\n        <div class=\"card\">\n          <div\
+        \ class=\"card-block\">\n            <h5><strong>Contributors</strong></h5>\n\
+        \            <div class=\"m-b-2\">\n              <div>\n                <a\
+        \ href=\"/user/pingou\">\n                  <img class=\"avatar circle\" src=\"\
+        https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                  Pierre-YvesChibon (pingou)\n                </a>\n \
+        \               -  main admin\n              </div>\n                  <div>\n\
+        \                    <a href=\"/user/ryanlerch\">\n                      <img\
+        \ class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/de5bf8d06663adb3bb1b8d49ccab259828fad7dddeb233b073d0c447d79b4c14?s=20&d=retro\"\
+        />\n                      Ryan Lerch (ryanlerch)\n                    </a>\n\
+        \                    - admin\n                  </div>\n                 \
+        \ <div>\n                    <a href=\"/user/puiterwijk\">\n             \
+        \         <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/85f8b5f58b9808429ad69ba3722d64da01ce850635e348e838dfdc053e1140e3?s=20&d=retro\"\
+        />\n                      Patrick &#34;\u30DE\u30EB\u30BF\u30A4\u30F3\u30A2\
+        \u30F3\u30C9\u30EC\u30A2\u30B9&#34; Uiterwijk (puiterwijk)\n             \
+        \       </a>\n                    - commit\n                  </div>\n   \
+        \               <div>\n                    <a href=\"/user/vivekanand1101\"\
+        >\n                      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/342d1c36d5c2f6984933a1238581dfbe74a08bf09ccaf110793703f1e8067169?s=20&d=retro\"\
+        />\n                      Vivek Anand (vivekanand1101)\n                 \
+        \   </a>\n                    - ticket\n                  </div>\n       \
+        \           <div>\n                    <a href=\"/user/farhaan\">\n      \
+        \                <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/b681ddfaffa293b03c57e5f0ed90eb1e06a8f748f4dccdccef8cdd301c66c9ae?s=20&d=retro\"\
+        />\n                      Farhaan Bukhsh (farhaan)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/cverna\">\n                \
+        \      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/c7844b93c43c862adaa29920c09bdfb91efed57a843203e5d0b72673e664c003?s=20&d=retro\"\
+        />\n                      Clement Verna (cverna)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/jcline\">\n                \
+        \      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/1a108f46a960aa35efcabd0b779cd59b8c3aba2927213fe63373921e1aa3fac4?s=20&d=retro\"\
+        />\n                      Jeremy Cline (jcline)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/lslebodn\">\n              \
+        \        <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/8c6fcfe0eec099f1f01f5c237935bb5c97f2063a5c1068e57c5815e0bfecbf70?s=20&d=retro\"\
+        />\n                      Lukas Slebodnik (lslebodn)\n                   \
+        \ </a>\n                    - ticket\n                  </div>\n         \
+        \         <div>\n                    <a href=\"/user/mprahl\">\n         \
+        \             <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/ecae5ad9b37619a38e699de00049e449f120b433c9f26a8beb05b990dfc54c9d?s=20&d=retro\"\
+        />\n                      Matt Prahl (mprahl)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n            </div>\n\
+        \            <h5><strong>Branches</strong></h5>\n            <div class=\"\
+        m-b-2\">\n              <div id=\"branch-master\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"\n          \
+        \          title=\"Default branch\"></span>\n                  <strong title=\"\
+        Currently viewing branch master\" data-toggle=\"tooltip\">master</strong>\n\
+        \                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                  <span class=\"oi text-muted\" data-glyph=\"star\"\n \
+        \                   title=\"Default branch\"></span>\n                </div>\n\
+        \              </div>\n\n              <div id=\"branch-2.9.x\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/2.9.x\"\n           \
+        \         title=\"2.9.x\" data-toggle=\"tooltip\">2.9.x\n                \
+        \  </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-alembic_changes\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/alembic_changes\"\n        \
+        \            title=\"alembic_changes\" data-toggle=\"tooltip\">alembic_changes\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-confirm_merge\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/confirm_merge\"\n   \
+        \                 title=\"confirm_merge\" data-toggle=\"tooltip\">confirm_merge\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-css_tables\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/css_tables\"\n      \
+        \              title=\"css_tables\" data-toggle=\"tooltip\">css_tables\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_del_project\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_del_project\"\n \
+        \                   title=\"fix_del_project\" data-toggle=\"tooltip\">fix_del_project\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_delete\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_delete\"\n      \
+        \              title=\"fix_delete\" data-toggle=\"tooltip\">fix_delete\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_diff_refs\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_diff_refs\"\n   \
+        \                 title=\"fix_diff_refs\" data-toggle=\"tooltip\">fix_diff_refs\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_doc\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_doc\"\n                \
+        \    title=\"fix_doc\" data-toggle=\"tooltip\">fix_doc\n                 \
+        \ </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_linking\" class=\"repoinfo-branchlistitem row\">\n\
+        \                <div class=\"branch_name col-md-8\">\n                  <span\
+        \ class=\"oi text-muted\" data-glyph=\"random\"></span>\n                \
+        \  <a class=\"\" href=\"/pagure/branch/fix_linking\"\n                   \
+        \ title=\"fix_linking\" data-toggle=\"tooltip\">fix_linking\n            \
+        \      </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_pygit_error\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_pygit_error\"\n        \
+        \            title=\"fix_pygit_error\" data-toggle=\"tooltip\">fix_pygit_error\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_reply_to\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_reply_to\"\n    \
+        \                title=\"fix_reply_to\" data-toggle=\"tooltip\">fix_reply_to\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_sse\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_sse\"\n                \
+        \    title=\"fix_sse\" data-toggle=\"tooltip\">fix_sse\n                 \
+        \ </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_sse_status\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_sse_status\"\n         \
+        \           title=\"fix_sse_status\" data-toggle=\"tooltip\">fix_sse_status\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_templates\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_templates\"\n   \
+        \                 title=\"fix_templates\" data-toggle=\"tooltip\">fix_templates\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fixes\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fixes\"\n                  \
+        \  title=\"fixes\" data-toggle=\"tooltip\">fixes\n                  </a>\n\
+        \n                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                </div>\n              </div>\n              <div id=\"\
+        branch-force_flag\" class=\"repoinfo-branchlistitem row\">\n             \
+        \   <div class=\"branch_name col-md-8\">\n                  <span class=\"\
+        oi text-muted\" data-glyph=\"random\"></span>\n                  <a class=\"\
+        \" href=\"/pagure/branch/force_flag\"\n                    title=\"force_flag\"\
+        \ data-toggle=\"tooltip\">force_flag\n                  </a>\n\n         \
+        \       </div>\n                <div class=\"branch_del col-md-4\">\n    \
+        \            </div>\n              </div>\n              <div id=\"branch-gpg_check\"\
+        \ class=\"repoinfo-branchlistitem row\">\n                <div class=\"branch_name\
+        \ col-md-8\">\n                  <span class=\"oi text-muted\" data-glyph=\"\
+        random\"></span>\n                  <a class=\"\" href=\"/pagure/branch/gpg_check\"\
+        \n                    title=\"gpg_check\" data-toggle=\"tooltip\">gpg_check\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-logging_milter\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/logging_milter\"\n  \
+        \                  title=\"logging_milter\" data-toggle=\"tooltip\">logging_milter\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-mirroring\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/mirroring\"\n              \
+        \      title=\"mirroring\" data-toggle=\"tooltip\">mirroring\n           \
+        \       </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-py3_work2\" class=\"repoinfo-branchlistitem row\">\n \
+        \               <div class=\"branch_name col-md-8\">\n                  <span\
+        \ class=\"oi text-muted\" data-glyph=\"random\"></span>\n                \
+        \  <a class=\"\" href=\"/pagure/branch/py3_work2\"\n                    title=\"\
+        py3_work2\" data-toggle=\"tooltip\">py3_work2\n                  </a>\n\n\
+        \                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                </div>\n              </div>\n              <div id=\"\
+        branch-rel_2.2.z\" class=\"repoinfo-branchlistitem row\">\n              \
+        \  <div class=\"branch_name col-md-8\">\n                  <span class=\"\
+        oi text-muted\" data-glyph=\"random\"></span>\n                  <a class=\"\
+        \" href=\"/pagure/branch/rel_2.2.z\"\n                    title=\"rel_2.2.z\"\
+        \ data-toggle=\"tooltip\">rel_2.2.z\n                  </a>\n\n          \
+        \      </div>\n                <div class=\"branch_del col-md-4\">\n     \
+        \           </div>\n              </div>\n              <div id=\"branch-rel_2.3.z\"\
+        \ class=\"repoinfo-branchlistitem row\">\n                <div class=\"branch_name\
+        \ col-md-8\">\n                  <span class=\"oi text-muted\" data-glyph=\"\
+        random\"></span>\n                  <a class=\"\" href=\"/pagure/branch/rel_2.3.z\"\
+        \n                    title=\"rel_2.3.z\" data-toggle=\"tooltip\">rel_2.3.z\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-scroll_metadata\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/scroll_metadata\"\n \
+        \                   title=\"scroll_metadata\" data-toggle=\"tooltip\">scroll_metadata\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-utf_search\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/utf_search\"\n      \
+        \              title=\"utf_search\" data-toggle=\"tooltip\">utf_search\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \         </div>\n            <h5><strong>Source GIT URLs</strong>       \
+        \           <span class=\"pull-xs-right\"><a data-toggle=\"collapse\" href=\"\
+        #moregiturls\"\n                  aria-expanded=\"false\" aria-controls=\"\
+        moregiturls\"\n                  id=\"more_gits\">more</a></span></h5>\n \
+        \           <div>\n              <div class=\"form-group\">\n            \
+        \    <div class=\"input-group input-group-sm\">\n                  <div class=\"\
+        input-group-addon\">GIT</div>\n                  <input class=\"form-control\"\
+        \ type=\"text\" value=\"https://pagure.io/pagure.git\" readonly>\n       \
+        \         </div>\n              </div>\n              <div class=\"collapse\"\
+        \ id=\"moregiturls\">\n                  <h5><strong>Docs GIT URLs</strong></h5>\n\
+        \                  <div class=\"form-group\">\n                    <div class=\"\
+        input-group input-group-sm\">\n                      <div class=\"input-group-addon\"\
+        >GIT</div>\n                      <input class=\"form-control\" type=\"text\"\
+        \ value=\"https://pagure.io/docs/pagure.git\" readonly>\n                \
+        \    </div>\n                  </div>\n              </div>\n            </div>\n\
+        \          </div>\n          <div class=\"repo_date\" title=\"2015-05-13 20:38:10.552662\"\
+        >\n              created 2 years ago\n          </div>\n        </div>\n\n\
+        \          <div class=\"card\">\n            <div class=\"card-header\">\n\
+        \              <strong>Recent Commits in\n                <span class=\"pr-toplabel\"\
+        >\n                  <span class=\"oi\" data-glyph=\"random\"></span>\n  \
+        \                master\n                </span>\n              </strong>\n\
+        \            </div>\n            <div class=\"card-block p-a-0\">\n      \
+        \        <div class=\"list-group list-group-flush\">\n                  <a\
+        \ href=\"/pagure/c/75b9021afbb08a3bdc1128cb282ea9c2394c0b67\" class=\"list-group-item\"\
+        >\n                    <div class=\"commitdate\" title=\"Sep 21 2017 08:20:29\"\
+        >\n                      <small>\n                        <strong>\n     \
+        \                     <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                          Pierre-Yves Chibon\n                       \
+        \ </strong>\n                        commited 5 days ago\n               \
+        \       </small>\n                    </div>\n                    <div><small>\n\
+        \                      Add unit-tests for the fixes around loading issue template\n\
+        \                    </small></div>\n                  </a>\n            \
+        \      <a href=\"/pagure/c/fc1d7f885d8b658ce7ba2fbd9cf20692b32f7732\" class=\"\
+        list-group-item\">\n                    <div class=\"commitdate\" title=\"\
+        Sep 21 2017 08:20:29\">\n                      <small>\n                 \
+        \       <strong>\n                          <img class=\"avatar circle\" src=\"\
+        https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                          Pierre-Yves Chibon\n                       \
+        \ </strong>\n                        commited 5 days ago\n               \
+        \       </small>\n                    </div>\n                    <div><small>\n\
+        \                      Flake8 fix fixing the tests\n                    </small></div>\n\
+        \                  </a>\n                  <a href=\"/pagure/c/91c1f357adc77f0b21e15f6177c68ffc97f3ec67\"\
+        \ class=\"list-group-item\">\n                    <div class=\"commitdate\"\
+        \ title=\"Sep 21 2017 08:20:29\">\n                      <small>\n       \
+        \                 <strong>\n                          <img class=\"avatar\
+        \ circle\" src=\"https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                          Pierre-Yves Chibon\n                       \
+        \ </strong>\n                        commited 5 days ago\n               \
+        \       </small>\n                    </div>\n                    <div><small>\n\
+        \                      Adjust the documentation around ticket templates\n\
+        \                    </small></div>\n                  </a>\n            \
+        \  </div>\n              </div>\n            </div>\n          </div>\n  \
+        \    </div>\n    </div>\n\n\n</div>\n    </div>\n\n    <div class=\"footer\
+        \ p-t-1 p-b-1\">\n        <div class=\"container\">\n            <p class=\"\
+        text-muted credit\">\n         Copyright &copy; 2014-2017 Red Hat\n      \
+        \    <a href=\"https://pagure.io/pagure\">pagure</a> &mdash;\n          3.7.1\
+        \ &mdash; <a href=\"https://docs.pagure.org/pagure/usage/index.html\">Documentation</a>\n\
+        \            </p>\n            <p><a href=\"/ssh_info\">SSH Hostkey/Fingerprint</a></p>\n\
+        \        </div>\n    </div>\n\n    <script type=\"text/javascript\"\n    \
+        \    src=\"/static/vendor/jquery/jquery.min.js\">\n    </script>\n    <script\
+        \ type=\"text/javascript\"\n        src=\"/static/vendor/jquery-ui/jquery-ui.min.js\"\
+        >\n    </script>\n    <script type=\"text/javascript\"\n        src=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.js\"\
+        >\n    </script>\n    <script type=\"text/javascript\">\n$('[data-toggle=\"\
+        tooltip\"]').tooltip({placement : 'bottom'});\n    </script>\n\n<script type=\"\
+        text/javascript\">\n$(document).ready(function() {\n        var currentWatchStatusButton\
+        \ = $('#unwatch_button');\n    currentWatchStatusButton.prepend('<span class=\"\
+        oi\" data-glyph=\"circle-check\" style=\"padding-right:0.5em\"></span>');\n\
+        \    $('.watch-menu a').not(currentWatchStatusButton).css('padding-left',\
+        \ '2.85em');\n\n    $(\".watch-menu a\").click(function(){\n        var selectedValue\
+        \ = $(this).attr('id');\n        var watchProjectForm = $(\"#watch_project\"\
+        );\n        var action = watchProjectForm.attr('action');\n\n        if (selectedValue\
+        \ === \"watch_issues_button\") {\n            action = action.replace('/settings/0',\
+        \ '/settings/1');\n        } else if (selectedValue === \"watch_commits_button\"\
+        ) {\n            action = action.replace('/settings/0', '/settings/2');\n\
+        \        } else if (selectedValue === \"watch_issues_commits_button\") {\n\
+        \            action = action.replace('/settings/0', '/settings/3');\n    \
+        \    } else if (selectedValue === \"reset_button\") {\n            action\
+        \ = action.replace('/settings/0', '/settings/-1');\n        }\n\n        watchProjectForm.attr('action',\
+        \ action);\n        watchProjectForm.submit();\n    });\n});\n</script>\n\n\
+        <script type=\"text/javascript\">\n$(document).ready(function() {\n    $(\"\
+        .extra_gits\").hide();\n});\n\n$(function() {\n  $( \"#more_gits\" ).click(function()\
+        \ {\n      if ($( \"#more_gits\" ).html() == 'more') {\n        $( \"#more_gits\"\
+        \ ).html('less');\n      } else {\n        $( \"#more_gits\" ).html('more');\n\
+        \      }\n  });\n\n});\n</script>\n\n\n</body>\n</html>"}
+    headers:
+      Connection: [Keep-Alive]
+      Content-Length: ['31652']
+      Content-Type: [text/html; charset=utf-8]
+      Date: ['Tue, 26 Sep 2017 15:29:38 GMT']
+      Keep-Alive: ['timeout=5, max=100']
+      Server: [Apache/2.4.6 (Red Hat Enterprise Linux) OpenSSL/1.0.1e-fips mod_wsgi/3.4
+          Python/2.7.5]
+      Set-Cookie: ['pagure=eyJfcGVybWFuZW50Ijp0cnVlLCJjc3JmIjp7IiBiIjoiWm1Ga09UUTJOR1ExT1RJeVlqRTBNbUV5TlRBMVlqa3lOakZrT0dRNU1tUTBaR000T0dJMk53PT0ifX0.DKwD4g.-AtNMHr2H1oGruvPTfjxzELawsw;
+          Expires=Fri, 27-Oct-2017 15:29:38 GMT; Secure; HttpOnly; Path=/']
+      Strict-Transport-Security: [max-age=15768000; includeSubDomains; preload]
+    status: {code: 200, message: OK}
+version: 1
diff --git a/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidgets.test_private_get b/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidgets.test_private_get
new file mode 100644
index 0000000..8119372
--- /dev/null
+++ b/hubs/tests/vcr-request-data/hubs.tests.views.test_api_hub_widget.TestAPIHubWidgets.test_private_get
@@ -0,0 +1,450 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.12.5]
+    method: GET
+    uri: https://pagure.io/pagure
+  response:
+    body: {string: "<!DOCTYPE html>\n<html lang='en'>\n<head>\n    <meta http-equiv=\"\
+        Content-Type\" content=\"text/html; charset=UTF-8\"/>\n    <title>Overview\
+        \ - pagure - Pagure</title>\n    <link rel=\"shortcut icon\" type=\"image/vnd.microsoft.icon\"\
+        \n        href=\"/static/favicon.ico\"/>\n    <link href=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/pagure.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/vendor/open-iconic/css/open-iconic.min.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <link href=\"/static/fonts/fonts.css\"\
+        \n        rel=\"stylesheet\" type=\"text/css\" />\n    <link href=\"/static/vendor/hack_fonts/css/hack-extended.min.css\"\
+        \n        type=\"text/css\" rel=\"stylesheet\" />\n    <meta name=\"go-import\"\
+        \ content=\"pagure.io/pagure git https://pagure.io/pagure.git\">\n  </head>\n\
+        \  <body id=\"home\">\n  <!-- start masthead -->\n    <div class=\"masthead\"\
+        >\n      <div class=\"container\">\n        <div class=\"row\">\n        \
+        \  <div class=\"col-sm-3\">\n            <a href=\"/\">\n              <img\
+        \ height=40px src=\"/static/pagure-logo.png\"\n                alt=\"pagure\
+        \ Logo\" id=\"pagureLogo\"/>\n            </a>\n          </div>\n       \
+        \   <div class=\"col-sm-9\">\n            <div class=\"row\">\n\n  <nav class=\"\
+        navbar navbar-light p-t-0 p-b-0\">\n      <div class=\"container\">\n    \
+        \    <ul class=\"nav navbar-nav nav-underline pull-xs-right\">\n         \
+        \     <li class=\"nav-item p-l-1\">\n                <a class=\"nav-link btn\
+        \ btn-primary\" href=\"/login/?next=https://pagure.io/pagure\">Log In</a>\n\
+        \              </li>\n        </ul>\n      </div>\n    </nav>\n\n        \
+        \    </div>\n          </div>\n        </div>\n      </div>\n    </div><!--\
+        \ close masthead-->\n\n    <div class=\"bodycontent p-b-3\">\n\n\n<div class=\"\
+        repo-header p-t-1\">\n  <div class=\"container\">\n    <header>\n      <h2\
+        \ class=\"repo-name m-b-0\">\n<a href=\"/pagure\">pagure</a>\n        </h2>\n\
+        \        <div class=\"projectinfo m-t-1 m-b-1\">\nA git centered forge &nbsp;|\
+        \ &nbsp;<a class=\"inline\" href=\"https://pagure.io\">https://pagure.io</a>\
+        \        </div>\n\n    </header>\n    <ul class=\"nav nav-tabs nav-small\"\
+        >\n      <li class=\"nav-item\">\n        <a class=\"nav-link active\" href=\"\
+        /pagure\">\n            <span class=\"oi hidden-md-up\" data-glyph=\"home\"\
+        ></span>\n            <span class=\"hidden-sm-down\">Overview</span>\n   \
+        \     </a>\n      </li>\n        <li class=\"nav-item\" >\n          <a  class=\"\
+        nav-link\" href=\"/docs/pagure/\">\n              <span class=\"oi hidden-md-up\"\
+        \ data-glyph=\"book\"></span>\n              <span class=\"hidden-sm-down\"\
+        >Docs</span>\n          </a>\n        </li>\n\n        <li class=\"nav-item\"\
+        >\n          <a class=\"nav-link\" href=\"/pagure/commits/master\">\n    \
+        \          <span class=\"oi hidden-md-up\" data-glyph=\"spreadsheet\">\n \
+        \             </span><span class=\"hidden-sm-down\">Commits</span>\n     \
+        \       </a>\n        </li>\n\n        <li class=\"nav-item\">\n         \
+        \ <a class=\"nav-link\" href=\"/pagure/tree/master\">\n              <span\
+        \ class=\"oi hidden-md-up\" data-glyph=\"file\"></span>\n              <span\
+        \ class=\"hidden-sm-down\">Files</span>\n          </a>\n        </li>\n\n\
+        \        <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"\
+        /pagure/releases\">\n              <span class=\"oi hidden-md-up\" data-glyph=\"\
+        box\"></span>\n              <span class=\"hidden-sm-down\">Releases</span>\n\
+        \          </a>\n        </li>\n\n        <li class=\"nav-item\">\n      \
+        \      <a class=\"nav-link\" href=\"/pagure/issues\">\n                <span\
+        \ class=\"oi hidden-md-up\" data-glyph=\"warning\"></span>\n             \
+        \   <span class=\"hidden-sm-down\">Issues&nbsp;</span>\n                <span\
+        \ class=\"label label-default label-pill hidden-sm-down\">\n             \
+        \     339\n                </span>\n            </a>\n        </li>\n\n  \
+        \      <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"/pagure/pull-requests\"\
+        >\n              <span class=\"oi hidden-md-up\" data-glyph=\"task\"></span>\n\
+        \              <span class=\"hidden-sm-down\">Pull Requests&nbsp;</span>\n\
+        \              <span class=\"label label-default label-pill hidden-sm-down\"\
+        >\n                16\n              </span>\n          </a>\n        </li>\n\
+        \n        <li class=\"nav-item\">\n          <a class=\"nav-link\" href=\"\
+        /pagure/forks\">\n              <span class=\"oi hidden-md-up\" data-glyph=\"\
+        fork\"></span>\n              <span class=\"hidden-sm-down\">Forks&nbsp;</span>\n\
+        \              <span class=\"hidden-sm-down label label-default label-pill\"\
+        >\n                131\n              </span>\n            </a>\n        </li>\n\
+        \n\n    </ul>\n  </div>\n</div>\n\n<div class=\"container p-t-3\">\n  <div\
+        \ class=\"row\">\n        <div class=\"col-md-8\">\n            <section class=\"\
+        readme\">\n              <div class=\"document\">\n<div class=\"section\"\
+        >\n<h1>Pagure</h1>\n<table class=\"docutils field-list\">\n<colgroup><col>\n\
+        <col>\n</colgroup><tbody>\n<tr><th>Author:</th><td>Pierre-Yves Chibon &lt;<a\
+        \ href=\"mailto:pingou@pingoured.fr\">pingou@pingoured.fr</a>&gt;</td>\n</tr>\n\
+        </tbody>\n</table>\n<p>Pagure is a git-centered forge, python based using\
+        \ pygit2.</p>\n<p>With pagure you can host your project with its documentation,\
+        \ let your users\nreport issues or request enhancements using the ticketing\
+        \ system and build your\ncommunity of contributors by allowing them to fork\
+        \ your projects and contribute\nto it via the now-popular pull-request mechanism.</p>\n\
+        <p>Homepage: <a href=\"https://pagure.io/pagure\">https://pagure.io/pagure</a></p>\n\
+        <p>See it at work: <a href=\"https://pagure.io\">https://pagure.io</a></p>\n\
+        <p>Playground version: <a href=\"https://stg.pagure.io\">https://stg.pagure.io</a></p>\n\
+        </div>\n<div class=\"section\">\n<h1>Get it running</h1>\n<p>There are several\
+        \ options when it comes to a development environment. Vagrant\nwill provide\
+        \ you with a virtual machine which you can develop on, or you can\ninstall\
+        \ it directly on your host machine.</p>\n<div class=\"section\">\n<h2>Vagrant</h2>\n\
+        <p>For a more thorough introduction to Vagrant, see\n<a href=\"https://fedoraproject.org/wiki/Vagrant\"\
+        >https://fedoraproject.org/wiki/Vagrant</a>.</p>\n<p>An example Vagrantfile\
+        \ is provided as <code>Vagrantfile.example</code>. To use it,\njust copy it\
+        \ and install Vagrant:</p>\n<pre>$ cp dev/Vagrantfile.example Vagrantfile\n\
+        $ sudo dnf install ansible libvirt vagrant-libvirt vagrant-sshfs vagrant-hostmanager\n\
+        $ vagrant up\n</pre>\n<p>The default <code>Vagrantfile</code> forwards ports\
+        \ from the host to the guest,\nso you can interact with the application as\
+        \ if it were running on your\nhost machine.</p>\n<div class=\"note\">\n<p>Note</p>\n\
+        <p><code><span class=\"pre\">vagrant-hostmanager</span></code> will automatically\
+        \ maintain /etc/hosts for you so you\ncan access the development environment\
+        \ from the host using its hostname, which\nby default is <code><span class=\"\
+        pre\">pagure-dev.example.com</span></code>. You can choose not to use this\n\
+        functionality by simply not installing the <code><span class=\"pre\">vagrant-hostmanager</span></code>\
+        \ plugin, but\nif you want Pagure to provide valid URLs in the UI for git\
+        \ repositories, you\nwill need to adjust Pagure's configuration found in ~/pagure.cfg\
+        \ on the guest.</p>\n</div>\n</div>\n<div class=\"section\">\n<h2>Manually</h2>\n\
+        <ul>\n<li><p>Install the needed system libraries:</p>\n<pre>sudo dnf install\
+        \ git python2-virtualenv libgit2-devel \\\n                 libjpeg-devel\
+        \ gcc libffi-devel redhat-rpm-config\n</pre>\n<div class=\"note\">\n<p>Note</p>\n\
+        <p>Do note the version of libgit2 that you install, for example\nin <code><span\
+        \ class=\"pre\">libgit2-0.23.4-1</span></code> you need to keep in mind the\
+        \ <code>0.23</code></p>\n</div>\n<div class=\"note\">\n<p>Note</p>\n<p>On\
+        \ Fedora 23 and earlier or on RHEL and derivative (CentOS,\nScientific Linux)\
+        \ the package <cite>python2-virtualenv</cite> is named\n<cite>python-virtualenv</cite></p>\n\
+        </div>\n</li>\n<li><p>Retrieve the sources:</p>\n<pre>git clone https://pagure.io/pagure.git\n\
+        cd pagure\n</pre>\n</li>\n<li><p>Install dependencies</p>\n<ul>\n<li><p>create\
+        \ the virtualenv:</p>\n<pre>virtualenv pagure_env\nsource ./pagure_env/bin/activate\n\
+        </pre>\n</li>\n<li><p>Install the correct version of pygit2:</p>\n<pre>pip\
+        \ install pygit2==&lt;version of libgit2 found&gt;.*\n</pre>\n<p>So in our\
+        \ example:</p>\n<pre>pip install pygit2==0.23.*\n</pre>\n</li>\n<li><p>Install\
+        \ the rest of the dependencies:</p>\n<pre>pip install -r requirements.txt\n\
+        </pre>\n</li>\n</ul>\n</li>\n<li><p>Create the folder that will receive the\
+        \ projects, forks, docs, requests and\ntickets' git repo:</p>\n<pre>mkdir\
+        \ -p lcl/{repos,docs,forks,tickets,requests,remotes,attachments,releases}\n\
+        </pre>\n</li>\n<li><p>Create the inital database scheme:</p>\n<pre>python\
+        \ createdb.py\n</pre>\n</li>\n<li><p>Start a worker, in one terminal:</p>\n\
+        <pre>./runworker.py\n</pre>\n</li>\n<li><p>Run the application, in another\
+        \ terminal:</p>\n<pre>./runserver.py\n</pre>\n</li>\n<li><p>To get some profiling\
+        \ information you can also run it as:</p>\n<pre>./runserver.py --profile\n\
+        </pre>\n</li>\n</ul>\n<p>This will launch the application at <a href=\"http://127.0.0.1:5000\"\
+        >http://127.0.0.1:5000</a></p>\n<ul>\n<li><p>To run unit-tests on pagure</p>\n\
+        <ul>\n<li><p>Install the dependencies:</p>\n<pre>pip install -r tests_requirements.txt\n\
+        </pre>\n</li>\n<li><p>Run it:</p>\n<pre>./runtests.sh\n</pre>\n<div class=\"\
+        note\">\n<p>Note</p>\n<p>While testing for worker tasks, pagure uses celery\
+        \ in /usr/bin/\nCelery then looks for eventlet (which we use for testing only)\
+        \ at\nsystem level and not in virtualenv. You will need to install eventlet\n\
+        outside of your virtualenv if you are using one.</p>\n</div>\n</li>\n</ul>\n\
+        </li>\n</ul>\n</div>\n</div>\n</div>\n\n            </section>\n        </div>\n\
+        \      <div class=\"col-md-4\">\n        <div class=\"card\">\n          <div\
+        \ class=\"card-block\">\n            <h5><strong>Contributors</strong></h5>\n\
+        \            <div class=\"m-b-2\">\n              <div>\n                <a\
+        \ href=\"/user/pingou\">\n                  <img class=\"avatar circle\" src=\"\
+        https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                  Pierre-YvesChibon (pingou)\n                </a>\n \
+        \               -  main admin\n              </div>\n                  <div>\n\
+        \                    <a href=\"/user/ryanlerch\">\n                      <img\
+        \ class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/de5bf8d06663adb3bb1b8d49ccab259828fad7dddeb233b073d0c447d79b4c14?s=20&d=retro\"\
+        />\n                      Ryan Lerch (ryanlerch)\n                    </a>\n\
+        \                    - admin\n                  </div>\n                 \
+        \ <div>\n                    <a href=\"/user/puiterwijk\">\n             \
+        \         <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/85f8b5f58b9808429ad69ba3722d64da01ce850635e348e838dfdc053e1140e3?s=20&d=retro\"\
+        />\n                      Patrick &#34;\u30DE\u30EB\u30BF\u30A4\u30F3\u30A2\
+        \u30F3\u30C9\u30EC\u30A2\u30B9&#34; Uiterwijk (puiterwijk)\n             \
+        \       </a>\n                    - commit\n                  </div>\n   \
+        \               <div>\n                    <a href=\"/user/vivekanand1101\"\
+        >\n                      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/342d1c36d5c2f6984933a1238581dfbe74a08bf09ccaf110793703f1e8067169?s=20&d=retro\"\
+        />\n                      Vivek Anand (vivekanand1101)\n                 \
+        \   </a>\n                    - ticket\n                  </div>\n       \
+        \           <div>\n                    <a href=\"/user/farhaan\">\n      \
+        \                <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/b681ddfaffa293b03c57e5f0ed90eb1e06a8f748f4dccdccef8cdd301c66c9ae?s=20&d=retro\"\
+        />\n                      Farhaan Bukhsh (farhaan)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/cverna\">\n                \
+        \      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/c7844b93c43c862adaa29920c09bdfb91efed57a843203e5d0b72673e664c003?s=20&d=retro\"\
+        />\n                      Clement Verna (cverna)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/jcline\">\n                \
+        \      <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/1a108f46a960aa35efcabd0b779cd59b8c3aba2927213fe63373921e1aa3fac4?s=20&d=retro\"\
+        />\n                      Jeremy Cline (jcline)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n                \
+        \  <div>\n                    <a href=\"/user/lslebodn\">\n              \
+        \        <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/8c6fcfe0eec099f1f01f5c237935bb5c97f2063a5c1068e57c5815e0bfecbf70?s=20&d=retro\"\
+        />\n                      Lukas Slebodnik (lslebodn)\n                   \
+        \ </a>\n                    - ticket\n                  </div>\n         \
+        \         <div>\n                    <a href=\"/user/mprahl\">\n         \
+        \             <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/ecae5ad9b37619a38e699de00049e449f120b433c9f26a8beb05b990dfc54c9d?s=20&d=retro\"\
+        />\n                      Matt Prahl (mprahl)\n                    </a>\n\
+        \                    - ticket\n                  </div>\n            </div>\n\
+        \            <h5><strong>Branches</strong></h5>\n            <div class=\"\
+        m-b-2\">\n              <div id=\"branch-master\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"\n          \
+        \          title=\"Default branch\"></span>\n                  <strong title=\"\
+        Currently viewing branch master\" data-toggle=\"tooltip\">master</strong>\n\
+        \                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                  <span class=\"oi text-muted\" data-glyph=\"star\"\n \
+        \                   title=\"Default branch\"></span>\n                </div>\n\
+        \              </div>\n\n              <div id=\"branch-2.9.x\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/2.9.x\"\n           \
+        \         title=\"2.9.x\" data-toggle=\"tooltip\">2.9.x\n                \
+        \  </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-alembic_changes\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/alembic_changes\"\n        \
+        \            title=\"alembic_changes\" data-toggle=\"tooltip\">alembic_changes\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-confirm_merge\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/confirm_merge\"\n   \
+        \                 title=\"confirm_merge\" data-toggle=\"tooltip\">confirm_merge\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-css_tables\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/css_tables\"\n      \
+        \              title=\"css_tables\" data-toggle=\"tooltip\">css_tables\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_del_project\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_del_project\"\n \
+        \                   title=\"fix_del_project\" data-toggle=\"tooltip\">fix_del_project\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_delete\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_delete\"\n      \
+        \              title=\"fix_delete\" data-toggle=\"tooltip\">fix_delete\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_diff_refs\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_diff_refs\"\n   \
+        \                 title=\"fix_diff_refs\" data-toggle=\"tooltip\">fix_diff_refs\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_doc\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_doc\"\n                \
+        \    title=\"fix_doc\" data-toggle=\"tooltip\">fix_doc\n                 \
+        \ </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_linking\" class=\"repoinfo-branchlistitem row\">\n\
+        \                <div class=\"branch_name col-md-8\">\n                  <span\
+        \ class=\"oi text-muted\" data-glyph=\"random\"></span>\n                \
+        \  <a class=\"\" href=\"/pagure/branch/fix_linking\"\n                   \
+        \ title=\"fix_linking\" data-toggle=\"tooltip\">fix_linking\n            \
+        \      </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_pygit_error\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_pygit_error\"\n        \
+        \            title=\"fix_pygit_error\" data-toggle=\"tooltip\">fix_pygit_error\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_reply_to\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_reply_to\"\n    \
+        \                title=\"fix_reply_to\" data-toggle=\"tooltip\">fix_reply_to\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_sse\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_sse\"\n                \
+        \    title=\"fix_sse\" data-toggle=\"tooltip\">fix_sse\n                 \
+        \ </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-fix_sse_status\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fix_sse_status\"\n         \
+        \           title=\"fix_sse_status\" data-toggle=\"tooltip\">fix_sse_status\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fix_templates\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/fix_templates\"\n   \
+        \                 title=\"fix_templates\" data-toggle=\"tooltip\">fix_templates\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-fixes\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/fixes\"\n                  \
+        \  title=\"fixes\" data-toggle=\"tooltip\">fixes\n                  </a>\n\
+        \n                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                </div>\n              </div>\n              <div id=\"\
+        branch-force_flag\" class=\"repoinfo-branchlistitem row\">\n             \
+        \   <div class=\"branch_name col-md-8\">\n                  <span class=\"\
+        oi text-muted\" data-glyph=\"random\"></span>\n                  <a class=\"\
+        \" href=\"/pagure/branch/force_flag\"\n                    title=\"force_flag\"\
+        \ data-toggle=\"tooltip\">force_flag\n                  </a>\n\n         \
+        \       </div>\n                <div class=\"branch_del col-md-4\">\n    \
+        \            </div>\n              </div>\n              <div id=\"branch-gpg_check\"\
+        \ class=\"repoinfo-branchlistitem row\">\n                <div class=\"branch_name\
+        \ col-md-8\">\n                  <span class=\"oi text-muted\" data-glyph=\"\
+        random\"></span>\n                  <a class=\"\" href=\"/pagure/branch/gpg_check\"\
+        \n                    title=\"gpg_check\" data-toggle=\"tooltip\">gpg_check\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-logging_milter\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/logging_milter\"\n  \
+        \                  title=\"logging_milter\" data-toggle=\"tooltip\">logging_milter\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-mirroring\" class=\"repoinfo-branchlistitem row\"\
+        >\n                <div class=\"branch_name col-md-8\">\n                \
+        \  <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n         \
+        \         <a class=\"\" href=\"/pagure/branch/mirroring\"\n              \
+        \      title=\"mirroring\" data-toggle=\"tooltip\">mirroring\n           \
+        \       </a>\n\n                </div>\n                <div class=\"branch_del\
+        \ col-md-4\">\n                </div>\n              </div>\n            \
+        \  <div id=\"branch-py3_work2\" class=\"repoinfo-branchlistitem row\">\n \
+        \               <div class=\"branch_name col-md-8\">\n                  <span\
+        \ class=\"oi text-muted\" data-glyph=\"random\"></span>\n                \
+        \  <a class=\"\" href=\"/pagure/branch/py3_work2\"\n                    title=\"\
+        py3_work2\" data-toggle=\"tooltip\">py3_work2\n                  </a>\n\n\
+        \                </div>\n                <div class=\"branch_del col-md-4\"\
+        >\n                </div>\n              </div>\n              <div id=\"\
+        branch-rel_2.2.z\" class=\"repoinfo-branchlistitem row\">\n              \
+        \  <div class=\"branch_name col-md-8\">\n                  <span class=\"\
+        oi text-muted\" data-glyph=\"random\"></span>\n                  <a class=\"\
+        \" href=\"/pagure/branch/rel_2.2.z\"\n                    title=\"rel_2.2.z\"\
+        \ data-toggle=\"tooltip\">rel_2.2.z\n                  </a>\n\n          \
+        \      </div>\n                <div class=\"branch_del col-md-4\">\n     \
+        \           </div>\n              </div>\n              <div id=\"branch-rel_2.3.z\"\
+        \ class=\"repoinfo-branchlistitem row\">\n                <div class=\"branch_name\
+        \ col-md-8\">\n                  <span class=\"oi text-muted\" data-glyph=\"\
+        random\"></span>\n                  <a class=\"\" href=\"/pagure/branch/rel_2.3.z\"\
+        \n                    title=\"rel_2.3.z\" data-toggle=\"tooltip\">rel_2.3.z\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-scroll_metadata\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/scroll_metadata\"\n \
+        \                   title=\"scroll_metadata\" data-toggle=\"tooltip\">scroll_metadata\n\
+        \                  </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \           <div id=\"branch-utf_search\" class=\"repoinfo-branchlistitem\
+        \ row\">\n                <div class=\"branch_name col-md-8\">\n         \
+        \         <span class=\"oi text-muted\" data-glyph=\"random\"></span>\n  \
+        \                <a class=\"\" href=\"/pagure/branch/utf_search\"\n      \
+        \              title=\"utf_search\" data-toggle=\"tooltip\">utf_search\n \
+        \                 </a>\n\n                </div>\n                <div class=\"\
+        branch_del col-md-4\">\n                </div>\n              </div>\n   \
+        \         </div>\n            <h5><strong>Source GIT URLs</strong>       \
+        \           <span class=\"pull-xs-right\"><a data-toggle=\"collapse\" href=\"\
+        #moregiturls\"\n                  aria-expanded=\"false\" aria-controls=\"\
+        moregiturls\"\n                  id=\"more_gits\">more</a></span></h5>\n \
+        \           <div>\n              <div class=\"form-group\">\n            \
+        \    <div class=\"input-group input-group-sm\">\n                  <div class=\"\
+        input-group-addon\">GIT</div>\n                  <input class=\"form-control\"\
+        \ type=\"text\" value=\"https://pagure.io/pagure.git\" readonly>\n       \
+        \         </div>\n              </div>\n              <div class=\"collapse\"\
+        \ id=\"moregiturls\">\n                  <h5><strong>Docs GIT URLs</strong></h5>\n\
+        \                  <div class=\"form-group\">\n                    <div class=\"\
+        input-group input-group-sm\">\n                      <div class=\"input-group-addon\"\
+        >GIT</div>\n                      <input class=\"form-control\" type=\"text\"\
+        \ value=\"https://pagure.io/docs/pagure.git\" readonly>\n                \
+        \    </div>\n                  </div>\n              </div>\n            </div>\n\
+        \          </div>\n          <div class=\"repo_date\" title=\"2015-05-13 20:38:10.552662\"\
+        >\n              created 2 years ago\n          </div>\n        </div>\n\n\
+        \          <div class=\"card\">\n            <div class=\"card-header\">\n\
+        \              <strong>Recent Commits in\n                <span class=\"pr-toplabel\"\
+        >\n                  <span class=\"oi\" data-glyph=\"random\"></span>\n  \
+        \                master\n                </span>\n              </strong>\n\
+        \            </div>\n            <div class=\"card-block p-a-0\">\n      \
+        \        <div class=\"list-group list-group-flush\">\n                  <a\
+        \ href=\"/pagure/c/75b9021afbb08a3bdc1128cb282ea9c2394c0b67\" class=\"list-group-item\"\
+        >\n                    <div class=\"commitdate\" title=\"Sep 21 2017 08:20:29\"\
+        >\n                      <small>\n                        <strong>\n     \
+        \                     <img class=\"avatar circle\" src=\"https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                          Pierre-Yves Chibon\n                       \
+        \ </strong>\n                        commited 5 days ago\n               \
+        \       </small>\n                    </div>\n                    <div><small>\n\
+        \                      Add unit-tests for the fixes around loading issue template\n\
+        \                    </small></div>\n                  </a>\n            \
+        \      <a href=\"/pagure/c/fc1d7f885d8b658ce7ba2fbd9cf20692b32f7732\" class=\"\
+        list-group-item\">\n                    <div class=\"commitdate\" title=\"\
+        Sep 21 2017 08:20:29\">\n                      <small>\n                 \
+        \       <strong>\n                          <img class=\"avatar circle\" src=\"\
+        https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                          Pierre-Yves Chibon\n                       \
+        \ </strong>\n                        commited 5 days ago\n               \
+        \       </small>\n                    </div>\n                    <div><small>\n\
+        \                      Flake8 fix fixing the tests\n                    </small></div>\n\
+        \                  </a>\n                  <a href=\"/pagure/c/91c1f357adc77f0b21e15f6177c68ffc97f3ec67\"\
+        \ class=\"list-group-item\">\n                    <div class=\"commitdate\"\
+        \ title=\"Sep 21 2017 08:20:29\">\n                      <small>\n       \
+        \                 <strong>\n                          <img class=\"avatar\
+        \ circle\" src=\"https://seccdn.libravatar.org/avatar/b3ee7bb4de70b6522c2478df3b4cd6322b5ec5d62ac7ceb1128e3d4ff42f6928?s=20&d=retro\"\
+        />\n                          Pierre-Yves Chibon\n                       \
+        \ </strong>\n                        commited 5 days ago\n               \
+        \       </small>\n                    </div>\n                    <div><small>\n\
+        \                      Adjust the documentation around ticket templates\n\
+        \                    </small></div>\n                  </a>\n            \
+        \  </div>\n              </div>\n            </div>\n          </div>\n  \
+        \    </div>\n    </div>\n\n\n</div>\n    </div>\n\n    <div class=\"footer\
+        \ p-t-1 p-b-1\">\n        <div class=\"container\">\n            <p class=\"\
+        text-muted credit\">\n         Copyright &copy; 2014-2017 Red Hat\n      \
+        \    <a href=\"https://pagure.io/pagure\">pagure</a> &mdash;\n          3.7.1\
+        \ &mdash; <a href=\"https://docs.pagure.org/pagure/usage/index.html\">Documentation</a>\n\
+        \            </p>\n            <p><a href=\"/ssh_info\">SSH Hostkey/Fingerprint</a></p>\n\
+        \        </div>\n    </div>\n\n    <script type=\"text/javascript\"\n    \
+        \    src=\"/static/vendor/jquery/jquery.min.js\">\n    </script>\n    <script\
+        \ type=\"text/javascript\"\n        src=\"/static/vendor/jquery-ui/jquery-ui.min.js\"\
+        >\n    </script>\n    <script type=\"text/javascript\"\n        src=\"https://apps.fedoraproject.org/global/fedora-bootstrap-1.0.1/fedora-bootstrap.js\"\
+        >\n    </script>\n    <script type=\"text/javascript\">\n$('[data-toggle=\"\
+        tooltip\"]').tooltip({placement : 'bottom'});\n    </script>\n\n<script type=\"\
+        text/javascript\">\n$(document).ready(function() {\n        var currentWatchStatusButton\
+        \ = $('#unwatch_button');\n    currentWatchStatusButton.prepend('<span class=\"\
+        oi\" data-glyph=\"circle-check\" style=\"padding-right:0.5em\"></span>');\n\
+        \    $('.watch-menu a').not(currentWatchStatusButton).css('padding-left',\
+        \ '2.85em');\n\n    $(\".watch-menu a\").click(function(){\n        var selectedValue\
+        \ = $(this).attr('id');\n        var watchProjectForm = $(\"#watch_project\"\
+        );\n        var action = watchProjectForm.attr('action');\n\n        if (selectedValue\
+        \ === \"watch_issues_button\") {\n            action = action.replace('/settings/0',\
+        \ '/settings/1');\n        } else if (selectedValue === \"watch_commits_button\"\
+        ) {\n            action = action.replace('/settings/0', '/settings/2');\n\
+        \        } else if (selectedValue === \"watch_issues_commits_button\") {\n\
+        \            action = action.replace('/settings/0', '/settings/3');\n    \
+        \    } else if (selectedValue === \"reset_button\") {\n            action\
+        \ = action.replace('/settings/0', '/settings/-1');\n        }\n\n        watchProjectForm.attr('action',\
+        \ action);\n        watchProjectForm.submit();\n    });\n});\n</script>\n\n\
+        <script type=\"text/javascript\">\n$(document).ready(function() {\n    $(\"\
+        .extra_gits\").hide();\n});\n\n$(function() {\n  $( \"#more_gits\" ).click(function()\
+        \ {\n      if ($( \"#more_gits\" ).html() == 'more') {\n        $( \"#more_gits\"\
+        \ ).html('less');\n      } else {\n        $( \"#more_gits\" ).html('more');\n\
+        \      }\n  });\n\n});\n</script>\n\n\n</body>\n</html>"}
+    headers:
+      Connection: [Keep-Alive]
+      Content-Length: ['31652']
+      Content-Type: [text/html; charset=utf-8]
+      Date: ['Tue, 26 Sep 2017 15:29:39 GMT']
+      Keep-Alive: ['timeout=5, max=100']
+      Server: [Apache/2.4.6 (Red Hat Enterprise Linux) OpenSSL/1.0.1e-fips mod_wsgi/3.4
+          Python/2.7.5]
+      Set-Cookie: ['pagure=eyJfcGVybWFuZW50Ijp0cnVlLCJjc3JmIjp7IiBiIjoiWVRBNU16ZGpNV0ptTlRkaU1qRm1OR0V3WlRjMk1UWXhOREUwTUdGa05qWXhPVEUwTjJRNU13PT0ifX0.DKwD5A.sIltPbKIPmLrrToF0bppHY_o6Cs;
+          Expires=Fri, 27-Oct-2017 15:29:40 GMT; Secure; HttpOnly; Path=/']
+      Strict-Transport-Security: [max-age=15768000; includeSubDomains; preload]
+    status: {code: 200, message: OK}
+version: 1
diff --git a/hubs/tests/views/test_api.py b/hubs/tests/views/test_api.py
deleted file mode 100644
index abf1e3d..0000000
--- a/hubs/tests/views/test_api.py
+++ /dev/null
@@ -1,151 +0,0 @@
-from __future__ import unicode_literals
-
-from six.moves.urllib.parse import urlparse
-
-import hubs.tests
-import hubs.models
-from hubs.app import app
-
-
-def usernames(collection): return [u.username for u in collection]
-
-
-class TestHubSubscribe(hubs.tests.APPTest):
-    user = hubs.tests.FakeAuthorization('decause')
-
-    def test_subscribe_redirects_when_logged_out(self):
-        hub = hubs.models.Hub.by_name('infra')
-        resp = self.app.post('/api/hub/{}/subscribe'.format(hub.name),
-                             follow_redirects=False)
-        self.assertEqual(resp.status_code, 302)
-        self.assertEqual(urlparse(resp.location).path, '/login')
-
-    def test_subscribe_when_logged_in(self):
-        hub = hubs.models.Hub.by_name('infra')
-        with hubs.tests.auth_set(app, self.user):
-            resp = self.app.post('/api/hub/{}/subscribe'.format(hub.name),
-                                 follow_redirects=True)
-            self.assertEqual(resp.status_code, 200)
-            # Need to find the Hub again to avoid DetachedInstanceError
-            h = hubs.models.Hub.by_name('infra')
-            self.assertTrue(self.user.username in usernames(h.subscribers))
-
-
-class TestHubUnsubscribe(hubs.tests.APPTest):
-    user = hubs.tests.FakeAuthorization('decause')
-
-    def test_unsubscribe_redirects_when_logged_out(self):
-        hub = hubs.models.Hub.by_name('infra')
-        resp = self.app.post('/api/hub/{}/unsubscribe'.format(hub.name),
-                             follow_redirects=False)
-        self.assertEqual(resp.status_code, 302)
-        self.assertEqual(urlparse(resp.location).path, '/login')
-
-    def test_unsubscribe_when_logged_in(self):
-        hub = hubs.models.Hub.by_name('infra')
-        # Need a real user model to subscribe to the hub
-        User = hubs.models.User.by_username(self.user.username)
-        hub.subscribe(User)
-
-        self.assertTrue(self.user.username in usernames(hub.subscribers))
-
-        with hubs.tests.auth_set(app, self.user):
-            resp = self.app.post('/api/hub/{}/unsubscribe'.format(hub.name),
-                                 follow_redirects=True)
-            self.assertEqual(resp.status_code, 200)
-            h = hubs.models.Hub.by_name('infra')
-            self.assertTrue(self.user.username not in usernames(h.subscribers))
-
-
-class TestHubStar(hubs.tests.APPTest):
-    user = hubs.tests.FakeAuthorization('decause')
-
-    def test_star_redirects_when_logged_out(self):
-        hub = hubs.models.Hub.by_name('infra')
-        resp = self.app.post('/api/hub/{}/star'.format(hub.name),
-                             follow_redirects=False)
-        self.assertEqual(resp.status_code, 302)
-        self.assertEqual(urlparse(resp.location).path, '/login')
-
-    def test_star_when_logged_in(self):
-        hub = hubs.models.Hub.by_name('infra')
-        with hubs.tests.auth_set(app, self.user):
-            resp = self.app.post('/api/hub/{}/star'.format(hub.name),
-                                 follow_redirects=True)
-
-            self.assertEqual(resp.status_code, 200)
-            h = hubs.models.Hub.by_name('infra')
-            self.assertTrue(self.user.username in usernames(h.stargazers))
-
-
-class TestHubUnstar(hubs.tests.APPTest):
-    user = hubs.tests.FakeAuthorization('decause')
-
-    def test_unstar_redirects_when_logged_out(self):
-        hub = hubs.models.Hub.by_name('infra')
-        resp = self.app.post('/api/hub/{}/unstar'.format(hub.name),
-                             follow_redirects=False)
-        self.assertEqual(resp.status_code, 302)
-        self.assertEqual(urlparse(resp.location).path, '/login')
-
-    def test_unstar_when_logged_in(self):
-        hub = hubs.models.Hub.by_name('infra')
-        # Need a real user model to subscribe to the hub
-        User = hubs.models.User.by_username(self.user.username)
-        hub.subscribe(User, role='stargazer')
-
-        self.assertTrue(self.user.username in [u.username for
-                                               u in hub.stargazers])
-
-        with hubs.tests.auth_set(app, self.user):
-            resp = self.app.post('/api/hub/{}/unstar'.format(hub.name),
-                                 follow_redirects=True)
-            self.assertEqual(resp.status_code, 200)
-            h = hubs.models.Hub.by_name('infra')
-            self.assertTrue(self.user.username not in usernames(h.stargazers))
-
-
-class TestHubJoin(hubs.tests.APPTest):
-    user = hubs.tests.FakeAuthorization('decause')
-
-    def test_join_redirects_when_logged_out(self):
-        hub = hubs.models.Hub.by_name('infra')
-        resp = self.app.post('/api/hub/{}/join'.format(hub.name),
-                             follow_redirects=False)
-        self.assertEqual(resp.status_code, 302)
-        self.assertEqual(urlparse(resp.location).path, '/login')
-
-    def test_join_when_logged_in(self):
-        hub = hubs.models.Hub.by_name('infra')
-        with hubs.tests.auth_set(app, self.user):
-            resp = self.app.post('/api/hub/{}/join'.format(hub.name),
-                                 follow_redirects=True)
-
-            self.assertEqual(resp.status_code, 200)
-            h = hubs.models.Hub.by_name('infra')
-            self.assertTrue(self.user.username in usernames(h.members))
-
-
-class TestHubLeave(hubs.tests.APPTest):
-    user = hubs.tests.FakeAuthorization('decause')
-
-    def test_leave_redirects_when_logged_out(self):
-        hub = hubs.models.Hub.by_name('infra')
-        resp = self.app.post('/api/hub/{}/leave'.format(hub.name),
-                             follow_redirects=False)
-        self.assertEqual(resp.status_code, 302)
-        self.assertEqual(urlparse(resp.location).path, '/login')
-
-    def test_star_when_logged_in(self):
-        hub = hubs.models.Hub.by_name('infra')
-        # Need a real user model to subscribe to the hub
-        User = hubs.models.User.by_username(self.user.username)
-        hub.subscribe(User, role='member')
-
-        with hubs.tests.auth_set(app, self.user):
-            resp = self.app.post('/api/hub/{}/leave'.format(hub.name),
-                                 follow_redirects=True)
-
-            self.assertEqual(resp.status_code, 200)
-            h = hubs.models.Hub.by_name('infra')
-            self.assertTrue(self.user.username not in usernames(h.members))
diff --git a/hubs/tests/views/test_api_association.py b/hubs/tests/views/test_api_association.py
new file mode 100644
index 0000000..26eaaa5
--- /dev/null
+++ b/hubs/tests/views/test_api_association.py
@@ -0,0 +1,151 @@
+from __future__ import unicode_literals
+
+from six.moves.urllib.parse import urlparse
+
+import hubs.tests
+import hubs.models
+from hubs.app import app
+
+
+def usernames(collection): return [u.username for u in collection]
+
+
+class TestHubSubscribe(hubs.tests.APPTest):
+    user = hubs.tests.FakeAuthorization('decause')
+
+    def test_subscribe_redirects_when_logged_out(self):
+        hub = hubs.models.Hub.by_name('infra')
+        resp = self.app.post('/api/hubs/{}/subscribe'.format(hub.name),
+                             follow_redirects=False)
+        self.assertEqual(resp.status_code, 302)
+        self.assertEqual(urlparse(resp.location).path, '/login')
+
+    def test_subscribe_when_logged_in(self):
+        hub = hubs.models.Hub.by_name('infra')
+        with hubs.tests.auth_set(app, self.user):
+            resp = self.app.post('/api/hubs/{}/subscribe'.format(hub.name),
+                                 follow_redirects=True)
+            self.assertEqual(resp.status_code, 200)
+            # Need to find the Hub again to avoid DetachedInstanceError
+            h = hubs.models.Hub.by_name('infra')
+            self.assertTrue(self.user.nickname in usernames(h.subscribers))
+
+
+class TestHubUnsubscribe(hubs.tests.APPTest):
+    user = hubs.tests.FakeAuthorization('decause')
+
+    def test_unsubscribe_redirects_when_logged_out(self):
+        hub = hubs.models.Hub.by_name('infra')
+        resp = self.app.post('/api/hubs/{}/unsubscribe'.format(hub.name),
+                             follow_redirects=False)
+        self.assertEqual(resp.status_code, 302)
+        self.assertEqual(urlparse(resp.location).path, '/login')
+
+    def test_unsubscribe_when_logged_in(self):
+        hub = hubs.models.Hub.by_name('infra')
+        # Need a real user model to subscribe to the hub
+        User = hubs.models.User.by_username(self.user.nickname)
+        hub.subscribe(User)
+
+        self.assertTrue(self.user.nickname in usernames(hub.subscribers))
+
+        with hubs.tests.auth_set(app, self.user):
+            resp = self.app.post('/api/hubs/{}/unsubscribe'.format(hub.name),
+                                 follow_redirects=True)
+            self.assertEqual(resp.status_code, 200)
+            h = hubs.models.Hub.by_name('infra')
+            self.assertTrue(self.user.nickname not in usernames(h.subscribers))
+
+
+class TestHubStar(hubs.tests.APPTest):
+    user = hubs.tests.FakeAuthorization('decause')
+
+    def test_star_redirects_when_logged_out(self):
+        hub = hubs.models.Hub.by_name('infra')
+        resp = self.app.post('/api/hubs/{}/star'.format(hub.name),
+                             follow_redirects=False)
+        self.assertEqual(resp.status_code, 302)
+        self.assertEqual(urlparse(resp.location).path, '/login')
+
+    def test_star_when_logged_in(self):
+        hub = hubs.models.Hub.by_name('infra')
+        with hubs.tests.auth_set(app, self.user):
+            resp = self.app.post('/api/hubs/{}/star'.format(hub.name),
+                                 follow_redirects=True)
+
+            self.assertEqual(resp.status_code, 200)
+            h = hubs.models.Hub.by_name('infra')
+            self.assertTrue(self.user.nickname in usernames(h.stargazers))
+
+
+class TestHubUnstar(hubs.tests.APPTest):
+    user = hubs.tests.FakeAuthorization('decause')
+
+    def test_unstar_redirects_when_logged_out(self):
+        hub = hubs.models.Hub.by_name('infra')
+        resp = self.app.post('/api/hubs/{}/unstar'.format(hub.name),
+                             follow_redirects=False)
+        self.assertEqual(resp.status_code, 302)
+        self.assertEqual(urlparse(resp.location).path, '/login')
+
+    def test_unstar_when_logged_in(self):
+        hub = hubs.models.Hub.by_name('infra')
+        # Need a real user model to subscribe to the hub
+        User = hubs.models.User.by_username(self.user.nickname)
+        hub.subscribe(User, role='stargazer')
+
+        self.assertTrue(self.user.nickname in [u.username for
+                                               u in hub.stargazers])
+
+        with hubs.tests.auth_set(app, self.user):
+            resp = self.app.post('/api/hubs/{}/unstar'.format(hub.name),
+                                 follow_redirects=True)
+            self.assertEqual(resp.status_code, 200)
+            h = hubs.models.Hub.by_name('infra')
+            self.assertTrue(self.user.nickname not in usernames(h.stargazers))
+
+
+class TestHubJoin(hubs.tests.APPTest):
+    user = hubs.tests.FakeAuthorization('decause')
+
+    def test_join_redirects_when_logged_out(self):
+        hub = hubs.models.Hub.by_name('infra')
+        resp = self.app.post('/api/hubs/{}/join'.format(hub.name),
+                             follow_redirects=False)
+        self.assertEqual(resp.status_code, 302)
+        self.assertEqual(urlparse(resp.location).path, '/login')
+
+    def test_join_when_logged_in(self):
+        hub = hubs.models.Hub.by_name('infra')
+        with hubs.tests.auth_set(app, self.user):
+            resp = self.app.post('/api/hubs/{}/join'.format(hub.name),
+                                 follow_redirects=True)
+
+            self.assertEqual(resp.status_code, 200)
+            h = hubs.models.Hub.by_name('infra')
+            self.assertTrue(self.user.nickname in usernames(h.members))
+
+
+class TestHubLeave(hubs.tests.APPTest):
+    user = hubs.tests.FakeAuthorization('decause')
+
+    def test_leave_redirects_when_logged_out(self):
+        hub = hubs.models.Hub.by_name('infra')
+        resp = self.app.post('/api/hubs/{}/leave'.format(hub.name),
+                             follow_redirects=False)
+        self.assertEqual(resp.status_code, 302)
+        self.assertEqual(urlparse(resp.location).path, '/login')
+
+    def test_star_when_logged_in(self):
+        hub = hubs.models.Hub.by_name('infra')
+        # Need a real user model to subscribe to the hub
+        User = hubs.models.User.by_username(self.user.nickname)
+        hub.subscribe(User, role='member')
+
+        with hubs.tests.auth_set(app, self.user):
+            resp = self.app.post('/api/hubs/{}/leave'.format(hub.name),
+                                 follow_redirects=True)
+
+            self.assertEqual(resp.status_code, 200)
+            h = hubs.models.Hub.by_name('infra')
+            self.assertTrue(self.user.nickname not in usernames(h.members))
diff --git a/hubs/tests/views/test_api_hub_config.py b/hubs/tests/views/test_api_hub_config.py
new file mode 100644
index 0000000..b542ce1
--- /dev/null
+++ b/hubs/tests/views/test_api_hub_config.py
@@ -0,0 +1,185 @@
+from __future__ import unicode_literals
+
+import datetime
+
+from flask import json
+
+from hubs.app import app
+from hubs.models import Hub, User
+from hubs.tests import APPTest, FakeAuthorization, auth_set
+
+
+class TestAPIHubConfig(APPTest):
+
+    def test_get(self):
+        hub = Hub.by_name('ralph')
+        hub.last_refreshed = datetime.datetime(2017, 1, 1, 0, 0, 0)
+        self.session.commit()
+        user = FakeAuthorization('ralph')
+        response = self.check_url("/api/hubs/ralph/config", user=user)
+        expected_data = {
+            "user_hub": True,
+            "config": {
+                "avatar": "https://seccdn.libravatar.org/avatar/"
+                          "9c9f7784935381befc302fe3c814f9136e7a339"
+                          "53d0318761669b8643f4df55c?s=312&d=retro",
+                'chat_channel': None,
+                'chat_domain': None,
+                "left_width": 8,
+                "right_width": 4,
+                "summary": "Ralph",
+                "visibility": "public",
+            },
+            "mtime": "Sun, 01 Jan 2017 00:00:00 GMT",
+            "users": {
+                "owner": [
+                    {"username": "ralph", "fullname": "Ralph"},
+                ],
+                "member": [],
+            },
+            "name": "ralph",
+        }
+        response_data = json.loads(response.get_data(as_text=True))
+        self.assertDictEqual(
+            response_data, {"status": "OK", "data": expected_data})
+
+    def test_private_get(self):
+        hub = Hub.by_name('ralph')
+        hub.config.visibility = "private"
+        self.session.commit()
+        # Private hubs are not accessible to anonymous users.
+        response = self.check_url("/api/hubs/ralph/config", code=403)
+        self.assertDictEqual(
+            json.loads(response.get_data(as_text=True)),
+            {
+              "hub": "ralph",
+              "message": "This hub is for members only.",
+              "status": "ERROR"
+            }
+        )
+        # But they are accessible to members.
+        decause = User.by_username("decause")
+        hub.subscribe(decause, role='member')
+        self.check_url(
+            "/api/hubs/ralph/config", user=FakeAuthorization("decause"))
+
+    def test_put(self):
+        user = FakeAuthorization('ralph')
+        with auth_set(app, user):
+            result = self.app.put(
+                '/api/hubs/ralph/config',
+                content_type="application/json",
+                data=json.dumps({
+                    "config": {
+                        "summary": "changed value",
+                        "chat_domain": "",
+                    }
+                })
+            )
+        self.assertEqual(result.status_code, 200)
+        result_data = json.loads(result.get_data(as_text=True))
+        self.assertEqual(result_data["status"], "OK")
+        hub_config = Hub.query.get("ralph").config
+        self.assertEqual(hub_config.summary, "changed value")
+        self.assertEqual(
+            hub_config.chat_domain,
+            app.config["CHAT_NETWORKS"][0]["domain"])
+
+    def test_put_unknown_data(self):
+        # Unknown PUT data is silently ignored
+        user = FakeAuthorization('ralph')
+        with auth_set(app, user):
+            result = self.app.put(
+                '/api/hubs/ralph/config',
+                content_type="application/json",
+                data=json.dumps({"config": {"non_existant": "dummy"}}))
+        self.assertEqual(result.status_code, 200)
+        result_data = json.loads(result.get_data(as_text=True))
+        self.assertEqual(result_data["status"], "ERROR")
+        self.assertEqual(result_data["message"], "Invalid value(s)")
+        self.assertIn("non_existant", result_data["fields"])
+        self.assertEqual(
+            result_data["fields"]["non_existant"],
+            "Unexpected parameter."
+            )
+
+    def test_put_invalid_chat_domain(self):
+        user = FakeAuthorization('ralph')
+        with auth_set(app, user):
+            result = self.app.put(
+                '/api/hubs/ralph/config',
+                content_type="application/json",
+                data=json.dumps({"config": {"chat_domain": "dummy"}}))
+        self.assertEqual(result.status_code, 200)
+        result_data = json.loads(result.get_data(as_text=True))
+        self.assertEqual(result_data["status"], "ERROR")
+        self.assertEqual(result_data["message"], "Invalid value(s)")
+        self.assertIn("chat_domain", result_data["fields"])
+        self.assertEqual(
+            result_data["fields"]["chat_domain"],
+            "Unsupported chat domain."
+            )
+
+    def test_put_unauthorized(self):
+        user = FakeAuthorization('ralph')
+        with auth_set(app, user):
+            result = self.app.put(
+                '/api/hubs/decause/config',
+                content_type="application/json",
+                data=json.dumps({"config": {"summary": "Defaced!"}}))
+        self.assertEqual(result.status_code, 403)
+        hub_config = Hub.query.get("decause").config
+        self.assertEqual(hub_config.summary, "Decause")
+
+    def test_suggest_users_no_filter(self):
+        user = FakeAuthorization('ralph')
+        expected = [
+            dict(username=u.username, fullname=u.fullname)
+            for u in User.query.order_by(User.username).all()
+            ]
+        # Check without filter
+        response = self.check_url(
+            "/api/hubs/ralph/config/suggest-users", user=user)
+        result_data = json.loads(response.get_data(as_text=True))
+        self.assertEqual(result_data["status"], "OK")
+        self.assertListEqual(result_data["data"], expected)
+
+    def test_suggest_users_filter_owners(self):
+        # Filters on owners
+        user = FakeAuthorization('ralph')
+        expected = [
+            dict(username=u.username, fullname=u.fullname)
+            for u in
+            User.query.order_by(User.username).filter(
+                User.username != "ralph").all()
+            ]
+        response = self.check_url(
+            "/api/hubs/ralph/config/suggest-users?exclude-role=owner",
+            user=user)
+        result_data = json.loads(response.get_data(as_text=True))
+        self.assertEqual(result_data["status"], "OK")
+        self.assertListEqual(result_data["data"], expected)
+
+    def test_suggest_users_filter_members(self):
+        # Filters on members
+        user = FakeAuthorization('ralph')
+        hub = Hub.get('ralph')
+        decause = User.query.get("decause")
+        devyani7 = User.query.get("devyani7")
+        hub.subscribe(decause, "member")
+        hub.subscribe(devyani7, "member")
+        expected = [
+            dict(username=u.username, fullname=u.fullname)
+            for u in
+            User.query.order_by(
+                User.username
+            ).filter(
+                User.username != "decause",
+                User.username != "devyani7"
+            ).all()]
+        response = self.check_url(
+            "/api/hubs/ralph/config/suggest-users?exclude-role=member",
+            user=user)
+        result_data = json.loads(response.get_data(as_text=True))
+        self.assertEqual(result_data["status"], "OK")
+        self.assertListEqual(result_data["data"], expected)
diff --git a/hubs/tests/views/test_api_hub_widget.py b/hubs/tests/views/test_api_hub_widget.py
new file mode 100644
index 0000000..5684d29
--- /dev/null
+++ b/hubs/tests/views/test_api_hub_widget.py
@@ -0,0 +1,240 @@
+from __future__ import unicode_literals
+
+from flask import json
+
+from hubs.app import app
+from hubs.models import Hub, User, Widget
+from hubs.widgets import registry
+from hubs.tests import APPTest, FakeAuthorization, auth_set
+
+
+class TestAPIHubWidgets(APPTest):
+
+    def test_get_widgets(self):
+        expected_ids = [35, 36, 34, 37, 38, 39, 40, 41, 42, 43, 44, 56]
+        response = self.check_url("/api/hubs/ralph/widgets/")
+        response_data = json.loads(response.get_data(as_text=True))
+        self.assertEqual(response_data["status"], "OK")
+        self.assertListEqual(
+            [w["idx"] for w in response_data["data"]],
+            expected_ids)
+
+    def test_get_widgets_private(self):
+        hub = Hub.by_name('ralph')
+        hub.config.visibility = "private"
+        self.session.commit()
+        # Private hubs are not accessible to anonymous users.
+        response = self.check_url("/api/hubs/ralph/widgets/", code=403)
+        self.assertDictEqual(
+            json.loads(response.get_data(as_text=True)),
+            {
+              "hub": "ralph",
+              "message": "This hub is for members only.",
+              "status": "ERROR"
+            }
+        )
+        # But they are accessible to members.
+        decause = User.by_username("decause")
+        hub.subscribe(decause, role='member')
+        self.check_url(
+            "/api/hubs/ralph/widgets/", user=FakeAuthorization("decause"))
+
+    def test_get_widgets_preview(self):
+        hub = Hub.by_name('ralph')
+        hub.config.visibility = "preview"
+        meetings = Widget.query.filter_by(
+            hub=hub, plugin="meetings").first()
+        # set the widget to "restricted"
+        meetings.visibility = "restricted"
+        # Restricted widgets in preview hubs aren't displayed to
+        # anonymous users.
+        response = self.check_url("/api/hubs/ralph/widgets/")
+        response_data = json.loads(response.get_data(as_text=True))
+        self.assertEqual(response_data["status"], "OK")
+        self.assertNotIn(
+            "meetings", [w["name"] for w in response_data["data"]])
+        # But they are displayed to members.
+        response = self.check_url(
+            "/api/hubs/ralph/widgets/", user=FakeAuthorization('ralph'))
+        response_data = json.loads(response.get_data(as_text=True))
+        self.assertEqual(response_data["status"], "OK")
+        self.assertIn(
+            "meetings", [w["name"] for w in response_data["data"]])
+
+    def test_get_removed_widget(self):
+        hub = Hub.get("ralph")
+        widget = Widget(
+            hub=hub, plugin="does-not-exist",
+            left=True, index=-1, _config="{}")
+        self.session.add(widget)
+        response = self.check_url(
+            "/api/hubs/ralph/widgets/", user=FakeAuthorization('ralph'))
+        response_data = json.loads(response.get_data(as_text=True))
+        self.assertNotIn(
+            "does-not-exist", [w["name"] for w in response_data["data"]])
+
+    def test_get_available(self):
+        response = self.check_url("/api/widgets/")
+        response_data = json.loads(response.get_data(as_text=True))
+        self.assertEqual(response_data["status"], "OK")
+        self.assertListEqual(
+            [w["name"] for w in response_data["data"]],
+            list(registry.keys()))
+
+    def test_post_invalid_request(self):
+        invalid_data = [
+            {
+                "position": "left",
+                'config': {},
+            }, {
+                'name': 'about',
+                'position': 'right',
+            }, {
+                'name': 'about',
+                'config': {},
+            }, {
+                'name': 'about',
+                'config': {},
+                'position': 'invalid_position',
+            },
+        ]
+        user = FakeAuthorization('ralph')
+        with auth_set(app, user):
+            for data in invalid_data:
+                result = self.app.post(
+                    '/api/hubs/ralph/widgets/',
+                    content_type="application/json",
+                    data=json.dumps(data))
+                self.assertEqual(result.status_code, 400)
+                expected_str = 'Invalid request sent'
+                self.assertIn(expected_str, result.get_data(as_text=True))
+
+    def test_post_unknown_widget(self):
+        invalid_data = [
+            {
+                'name': 'invalid_widget_name',
+                'config': {},
+                'position': 'right',
+            },
+        ]
+        user = FakeAuthorization('ralph')
+        with auth_set(app, user):
+            for data in invalid_data:
+                result = self.app.post(
+                    '/api/hubs/ralph/widgets/',
+                    content_type="application/json",
+                    data=json.dumps(data))
+                self.assertEqual(result.status_code, 400)
+                expected_str = 'Unknown widget'
+                self.assertIn(expected_str, result.get_data(as_text=True))
+
+    def test_post_valid_widget_name_no_config(self):
+        user = FakeAuthorization('ralph')
+        with auth_set(app, user):
+            data = {
+                'name': 'memberships',
+                'position': 'right',
+                "config": {},
+                }
+            result = self.app.post(
+                '/api/hubs/ralph/widgets/',
+                content_type="application/json",
+                data=json.dumps(data))
+        self.assertEqual(result.status_code, 200)
+        self.assertEqual(
+            json.loads(result.get_data(as_text=True)),
+            {"status": "OK"})
+        response = self.check_url("/api/hubs/ralph/widgets/")
+        response_data = json.loads(response.get_data(as_text=True))
+        self.assertEqual(response_data["status"], "OK")
+        self.assertIn("memberships",
+                      [w["name"] for w in response_data["data"]])
+
+    def test_post_valid_widget_name_with_config(self):
+        self.assertEqual(
+            Widget.query.filter(
+                Hub.name == "ralph",
+                Widget.plugin == "about",
+            ).count(), 1)
+        data = {
+            "name": "about",
+            "config": {'text': 'text of widget'},
+            'position': 'right',
+            }
+        user = FakeAuthorization('ralph')
+        with auth_set(app, user):
+            result = self.app.post(
+                '/api/hubs/ralph/widgets/',
+                content_type="application/json",
+                data=json.dumps(data))
+        self.assertEqual(result.status_code, 200)
+        self.assertEqual(
+            json.loads(result.get_data(as_text=True)),
+            {"status": "OK"})
+        self.assertEqual(
+            Widget.query.filter(
+                Hub.name == "ralph",
+                Widget.plugin == "about",
+            ).count(), 2)
+
+
+class TestAPIHubWidget(APPTest):
+
+    def test_get_logged_in(self):
+        user = FakeAuthorization('ralph')
+        response = self.check_url("/api/hubs/ralph/widgets/37/", user=user)
+        response_data = json.loads(response.get_data(as_text=True))
+        self.assertEqual(response_data["status"], "OK")
+        self.assertEqual(response_data["data"]["name"], "fedmsgstats")
+
+    def test_get_logged_out(self):
+        hub = Hub.by_name('ralph')
+        hub.config.visibility = "private"
+        self.session.commit()
+        response = self.check_url("/api/hubs/ralph/widgets/31/", code=403)
+        response_data = json.loads(response.get_data(as_text=True))
+        self.assertEqual(response_data["status"], "ERROR")
+
+    def test_put_empty_data_logged_in(self):
+        user = FakeAuthorization('ralph')
+        with auth_set(app, user):
+            result = self.app.put(
+                '/api/hubs/ralph/widgets/37/',
+                content_type="application/json",
+                data=json.dumps({}))
+        self.assertEqual(result.status_code, 200)
+        self.assertEqual(
+            json.loads(result.get_data(as_text=True)),
+            {"status": "OK"})
+
+    def test_put_unauthorized(self):
+        user = FakeAuthorization('decause')
+        with auth_set(app, user):
+            result = self.app.put(
+                '/api/hubs/ralph/widgets/37/',
+                content_type="application/json",
+                data=json.dumps({"config": {"text": "Defaced!"}}))
+        self.assertEqual(result.status_code, 403)
+
+    def test_delete(self):
+        user = FakeAuthorization('ralph')
+        with auth_set(app, user):
+            result = self.app.delete('/api/hubs/ralph/widgets/37/')
+        self.assertEqual(result.status_code, 200)
+        self.assertEqual(
+            json.loads(result.get_data(as_text=True)),
+            {"status": "OK"})
+        response = self.check_url("/api/hubs/ralph/widgets/")
+        response_data = json.loads(response.get_data(as_text=True))
+        self.assertNotIn(37, [w["idx"] for w in response_data["data"]])
+
+    def test_delete_unauthorized(self):
+        user = FakeAuthorization('decause')
+        with auth_set(app, user):
+            response = self.app.delete('/api/hubs/ralph/widgets/37/')
+        self.assertEqual(response.status_code, 403)
+        response_data = json.loads(response.get_data(as_text=True))
+        self.assertEqual(response_data["status"], "ERROR")
+        response = self.check_url("/api/hubs/ralph/widgets/")
+        response_data = json.loads(response.get_data(as_text=True))
+        self.assertIn(37, [w["idx"] for w in response_data["data"]])
diff --git a/hubs/tests/views/test_hub_config.py b/hubs/tests/views/test_hub_config.py
deleted file mode 100644
index 9b66b03..0000000
--- a/hubs/tests/views/test_hub_config.py
+++ /dev/null
@@ -1,166 +0,0 @@
-from __future__ import unicode_literals
-
-from flask import json
-
-from hubs.app import app
-from hubs.models import Hub, HubConfig, User
-from hubs.tests import APPTest, FakeAuthorization, auth_set
-
-
-class HubConfigTestCase(APPTest):
-
-    def test_hub_config_get(self):
-        expected = {
-            "hubconfig": {
-                "summary": "Ralph",
-                "left_width": 8,
-                "avatar": (
-                    "https://seccdn.libravatar.org/avatar/9c9f7784935381befc30"
-                    "2fe3c814f9136e7a33953d0318761669b8643f4df55c"
-                    "?s=312&d=retro"
-                    ),
-                "chat_channel": None,
-                "chat_domain": None,
-                "visibility": "public",
-            },
-            "general": {
-                "chat_networks": app.config["CHAT_NETWORKS"],
-                "roles": ["owner", "member"],
-                "hub_visibility": HubConfig.VISIBILITY,
-            },
-            "users": {
-                "member": [],
-                "owner": [{
-                    "fullname": "Ralph",
-                    "locked": True,
-                    "role": "owner",
-                    "username": "ralph",
-                }],
-            },
-        }
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            url = '/ralph/config'
-            result = self.app.get(url)
-        self.assertEqual(result.status_code, 200)
-        result_data = json.loads(result.get_data(as_text=True))
-        self.assertEqual(result_data["status"], "OK")
-        self.assertDictEqual(result_data["result"], expected)
-
-    def test_hub_config_post(self):
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            url = '/ralph/config?category=config'
-            result = self.app.post(url, data={
-                "summary": "changed value",
-                "chat_domain": "",
-            })
-        self.assertEqual(result.status_code, 200)
-        result_data = json.loads(result.get_data(as_text=True))
-        self.assertEqual(result_data["status"], "OK")
-        self.assertEqual(
-            result_data["result"]["hubconfig"]["summary"],
-            "changed value")
-        self.assertEqual(
-            result_data["result"]["hubconfig"]["chat_domain"],
-            app.config["CHAT_NETWORKS"][0]["domain"])
-
-    def test_hub_config_post_unknown_post_data(self):
-        # Unknown POST data is silently ignored
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            url = '/ralph/config?category=config'
-            result = self.app.post(url, data={"non_existant": "dummy"})
-        self.assertEqual(result.status_code, 200)
-        result_data = json.loads(result.get_data(as_text=True))
-        self.assertEqual(result_data["status"], "ERROR")
-        self.assertEqual(result_data["message"], "Invalid value(s)")
-        self.assertIn("non_existant", result_data["fields"])
-        self.assertEqual(
-            result_data["fields"]["non_existant"],
-            "Unexpected parameter."
-            )
-
-    def test_hub_config_post_invalid_chat_domain(self):
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            url = '/ralph/config?category=config'
-            result = self.app.post(url, data={"chat_domain": "dummy"})
-        self.assertEqual(result.status_code, 200)
-        result_data = json.loads(result.get_data(as_text=True))
-        self.assertEqual(result_data["status"], "ERROR")
-        self.assertEqual(result_data["message"], "Invalid value(s)")
-        self.assertIn("chat_domain", result_data["fields"])
-        self.assertEqual(
-            result_data["fields"]["chat_domain"],
-            "Unsupported chat domain."
-            )
-
-    def test_hub_config_get_unauthorized(self):
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            url = '/decause/config'
-            result = self.app.get(url)
-        self.assertEqual(result.status_code, 403)
-
-    def test_hub_config_post_unauthorized(self):
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            url = '/decause/config?category=config'
-            result = self.app.post(url, data={"summary": "Defaced!"})
-        self.assertEqual(result.status_code, 403)
-
-    def test_hub_config_suggest_users_no_filter(self):
-        user = FakeAuthorization('ralph')
-        expected = [
-            u.username for u in
-            User.query.order_by(User.username).all()
-            ]
-        # Check without filter
-        with auth_set(app, user):
-            url = '/ralph/config/suggest-users'
-            result = self.app.get(url)
-        self.assertEqual(result.status_code, 200)
-        result_data = json.loads(result.get_data(as_text=True))
-        self.assertEqual(result_data["status"], "OK")
-        self.assertListEqual(result_data["results"], expected)
-
-    def test_hub_config_suggest_users_filter_owners(self):
-        # Filters on owners
-        user = FakeAuthorization('ralph')
-        expected = [
-            u.username for u in
-            User.query.order_by(User.username).filter(
-                User.username != "ralph").all()
-            ]
-        with auth_set(app, user):
-            url = '/ralph/config/suggest-users?exclude-role=owner'
-            result = self.app.get(url)
-        self.assertEqual(result.status_code, 200)
-        result_data = json.loads(result.get_data(as_text=True))
-        self.assertEqual(result_data["status"], "OK")
-        self.assertListEqual(result_data["results"], expected)
-
-    def test_hub_config_suggest_users_filter_members(self):
-        # Filters on members
-        user = FakeAuthorization('ralph')
-        hub = Hub.get('ralph')
-        decause = User.query.get("decause")
-        devyani7 = User.query.get("devyani7")
-        hub.subscribe(decause, "member")
-        hub.subscribe(devyani7, "member")
-        expected = [
-            u.username for u in
-            User.query.order_by(
-                User.username
-            ).filter(
-                User.username != "decause",
-                User.username != "devyani7"
-            ).all()]
-        with auth_set(app, user):
-            url = '/ralph/config/suggest-users?exclude-role=member'
-            result = self.app.get(url)
-        self.assertEqual(result.status_code, 200)
-        result_data = json.loads(result.get_data(as_text=True))
-        self.assertEqual(result_data["status"], "OK")
-        self.assertListEqual(result_data["results"], expected)
diff --git a/hubs/tests/views/test_hub_edit.py b/hubs/tests/views/test_hub_edit.py
deleted file mode 100644
index 2ff727e..0000000
--- a/hubs/tests/views/test_hub_edit.py
+++ /dev/null
@@ -1,114 +0,0 @@
-from __future__ import unicode_literals
-
-from six.moves.urllib.parse import urlparse
-from werkzeug.datastructures import ImmutableMultiDict
-
-from hubs.app import app
-from hubs.tests import APPTest, FakeAuthorization, auth_set
-
-
-class HubEditTestCase(APPTest):
-
-    def test_hub_edit_get_logged_out(self):
-        with auth_set(app, None):
-            result = self.app.get('/ralph/edit', follow_redirects=False)
-        self.assertEqual(result.status_code, 302)
-        self.assertEqual(urlparse(result.location).path, "/login")
-
-    def test_hub_edit_get_logged_in_not_owner(self):
-        user = FakeAuthorization('not_ralph')
-        with auth_set(app, user):
-            result = self.app.get('/ralph/edit', follow_redirects=True)
-            self.assertEqual(result.status_code, 403)
-
-    def test_hub_edit_get_logged_in_owner(self):
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            result = self.app.get('/ralph/edit', follow_redirects=True)
-            self.assertEqual(result.status_code, 200)
-
-    def test_hub_edit_post_logged_out(self):
-        with auth_set(app, None):
-            result = self.app.post('/ralph/edit', follow_redirects=False)
-        self.assertEqual(result.status_code, 302)
-        self.assertEqual(urlparse(result.location).path, "/login")
-
-    def test_hub_edit_post_logged_in_not_owner(self):
-        user = FakeAuthorization('not_ralph')
-        with auth_set(app, user):
-            result = self.app.post('/ralph/edit', follow_redirects=True)
-            self.assertEqual(result.status_code, 403)
-
-    def test_hub_edit_post_logged_in_owner_empty_data(self):
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            result = self.app.post('/ralph/edit', data={},
-                                   follow_redirects=True)
-            self.assertEqual(result.status_code, 200)
-            self.assertNotIn(
-                'Not logged in.  Click to <a href="/login">login</a>',
-                result.get_data(as_text=True))
-            self.assertIn(
-                '<a href="#" class="dropdown-item">Full Name: '
-                'fullname: ralph</a>', result.get_data(as_text=True))
-
-    def test_hub_edit_post_logged_in_owner_valid_data(self):
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            data = ImmutableMultiDict(
-                [('right_indexes[]', u'0'), ('right_indexes[]', u'1'),
-                 ('right_indexes[]', u'2'), ('right_indexes[]', u'3'),
-                 ('right_indexes[]', u'4'), ('right_indexes[]', u'5'),
-                 ('right_indexes[]', u'6'), ('right_indexes[]', u'7'),
-                 ('right_indexes[]', u'8'), ('right_widgets[]', u'32'),
-                 ('right_widgets[]', u'33'), ('right_widgets[]', u'34'),
-                 ('right_widgets[]', u'35'), ('right_widgets[]', u'36'),
-                 ('right_widgets[]', u'37'), ('right_widgets[]', u'38'),
-                 ('right_widgets[]', u'39'), ('right_widgets[]', u'40'),
-                 ('js', u'true'), ('left_indexes[]', u'0'),
-                 ('left_indexes[]', u'1'), ('left_widgets[]', u'31'),
-                 ('left_widgets[]', u'32')])
-            result = self.app.post('/ralph/edit', data=data,
-                                   follow_redirects=True)
-            self.assertEqual(result.status_code, 200)
-            self.assertEqual(result.get_data(as_text=True), 'ok')
-
-    def test_hub_edit_post_logged_in_owner_invalid_data_1(self):
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            # some indexes and widgets are not integers
-            data = ImmutableMultiDict(
-                [('right_indexes[]', u'0a'), ('right_indexes[]', u'1'),
-                 ('right_indexes[]', u'2'), ('right_indexes[]', u'3'),
-                 ('right_indexes[]', u'4'), ('right_indexes[]', u'5'),
-                 ('right_indexes[]', u'6'), ('right_indexes[]', u'7'),
-                 ('right_indexes[]', u'8'), ('right_widgets[]', u'32'),
-                 ('right_widgets[]', u'33a'), ('right_widgets[]', u'34'),
-                 ('right_widgets[]', u'35'), ('right_widgets[]', u'36'),
-                 ('right_widgets[]', u'37'), ('right_widgets[]', u'38'),
-                 ('right_widgets[]', u'39'), ('right_widgets[]', u'40'),
-                 ('js', u'true'), ('left_indexes[]', u'0a'),
-                 ('left_indexes[]', u'1'), ('left_widgets[]', u'31a'),
-                 ('left_widgets[]', u'32')])
-            result = self.app.post('/ralph/edit', data=data,
-                                   follow_redirects=True)
-            self.assertEqual(result.status_code, 400)
-
-    def test_hub_edit_post_logged_in_owner_invalid_data_2(self):
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            # indexes len don't match widgets len
-            data = ImmutableMultiDict(
-                [('right_indexes[]', u'1'), ('right_indexes[]', u'2'),
-                 ('right_indexes[]', u'3'), ('right_indexes[]', u'4'),
-                 ('right_indexes[]', u'5'), ('right_indexes[]', u'6'),
-                 ('right_indexes[]', u'7'), ('right_indexes[]', u'8'),
-                 ('right_widgets[]', u'32'), ('right_widgets[]', u'34'),
-                 ('right_widgets[]', u'35'), ('right_widgets[]', u'36'),
-                 ('right_widgets[]', u'37'), ('right_widgets[]', u'38'),
-                 ('right_widgets[]', u'39'), ('right_widgets[]', u'40'),
-                 ('js', u'true'), ('left_indexes[]', u'0'),
-                 ('left_indexes[]', u'1'), ('left_widgets[]', u'32')])
-            result = self.app.post('/ralph/edit', data=data,
-                                   follow_redirects=True)
-            self.assertEqual(result.status_code, 400)
diff --git a/hubs/tests/views/test_hub_widgets.py b/hubs/tests/views/test_hub_widgets.py
deleted file mode 100644
index bef72bb..0000000
--- a/hubs/tests/views/test_hub_widgets.py
+++ /dev/null
@@ -1,138 +0,0 @@
-from __future__ import unicode_literals
-
-from flask import json
-from six.moves.urllib.parse import urlparse
-
-from hubs.app import app
-from hubs.models import Hub, Widget
-from hubs.tests import APPTest, FakeAuthorization, auth_set
-
-
-class HubWidgetsTestCase(APPTest):
-
-    def test_hub_add_widget_get_no_args(self):
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            result = self.app.get('/ralph/add', follow_redirects=False)
-        self.assertEqual(result.status_code, 400)
-        expected_str = 'Invalid position provided'
-        self.assertIn(expected_str, result.get_data(as_text=True))
-
-    def test_hub_add_widget_get_with_args(self):
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            result = self.app.get('/ralph/add?position=right',
-                                  follow_redirects=True)
-        self.assertEqual(result.status_code, 200)
-        page_html = result.get_data(as_text=True)
-        self.assertIn('Adding a widget to hub: ralph', page_html)
-        self.assertIn('<form method="post" action="/ralph/add">', page_html)
-        self.assertIn('<input type="hidden" name="position" value="right" />',
-                      page_html)
-
-    def test_hub_add_widget_post_no_widget_name(self):
-        data = {"position": "left"}
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            result = self.app.post(
-                '/ralph/add', data=data, follow_redirects=False)
-        self.assertEqual(result.status_code, 400)
-        expected_str = 'Invalid request sent'
-        self.assertIn(expected_str, result.get_data(as_text=True))
-
-    def test_hub_add_widget_post_invalid_widget_name(self):
-        data = {'widget': 'invalid_widget_name',
-                'position': 'right'}
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            result = self.app.post(
-                '/ralph/add', data=data, follow_redirects=False)
-        self.assertEqual(result.status_code, 404)
-        expected_str = 'Unknown widget called'
-        self.assertIn(expected_str, result.get_data(as_text=True))
-
-    def test_hub_add_widget_post_valid_widget_name_no_args(self):
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            data = {
-                'widget': 'memberships',
-                'position': 'right',
-                }
-            result = self.app.post('/ralph/add', data=data)
-            self.assertEqual(result.status_code, 200)
-            self.assertEqual(
-                json.loads(result.get_data(as_text=True)),
-                {"status": "ADDED"})
-            result = self.app.get('/ralph/edit')
-            self.assertEqual(result.status_code, 200)
-            page_html = result.get_data(as_text=True)
-            self.assertIn('data-url="/ralph/w/memberships/', page_html)
-
-    def test_hub_add_widget_post_valid_widget_name_with_args(self):
-        self.assertEqual(
-            Widget.query.filter(
-                Hub.name == "ralph",
-                Widget.plugin == "about",
-            ).count(), 1)
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            data = {
-                'text': 'text of widget',
-                'position': 'right',
-                }
-            result = self.app.post('/ralph/add/about', data=data,
-                                   follow_redirects=False)
-            self.assertEqual(result.status_code, 200)
-            self.assertEqual(
-                json.loads(result.get_data(as_text=True)),
-                {"status": "ADDED"})
-            self.assertEqual(
-                Widget.query.filter(
-                    Hub.name == "ralph",
-                    Widget.plugin == "about",
-                ).count(), 2)
-
-    def test_hub_edit_widget_get_logged_in(self):
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            result = self.app.get('/ralph/37/edit', follow_redirects=True)
-            self.assertEqual(result.status_code, 200)
-            expected_str = '/ralph/37/edit'
-            self.assertIn(expected_str, result.get_data(as_text=True))
-
-    def test_hub_edit_widget_get_logged_out(self):
-        result = self.app.get('/ralph/31/edit', follow_redirects=False)
-        self.assertEqual(result.status_code, 302)
-        self.assertEqual(urlparse(result.location).path, "/login")
-
-    def test_hub_edit_widget_post_empty_data_logged_in(self):
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            data = {}
-            url = '/ralph/37/edit'
-            result = self.app.post(url, data=data, follow_redirects=False)
-            self.assertEqual(result.status_code, 302)
-            self.assertEqual(urlparse(result.location).path, '/ralph/edit')
-
-    def test_hub_add_widget_valid_side(self):
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            url = '/ralph/add/about?position=right'
-            result = self.app.get(url)
-            self.assertIn('Adding widget "about" to hub ralph',
-                          result.get_data(as_text=True))
-
-    def test_hub_add_widget_invalid_side(self):
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            url = '/ralph/add/about?position=invalid'
-            result = self.app.get(url)
-            self.assertEqual(result.status_code, 400)
-
-    def test_hub_delete(self):
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            url = '/ralph/37/delete'  # 37 is widget fedmsgstats for ralph
-            result = self.app.post(url)
-            self.assertEqual(result.status_code, 302)
-            self.assertIn('/ralph/edit', result.get_data(as_text=True))
diff --git a/hubs/tests/views/test_widget.py b/hubs/tests/views/test_widget.py
deleted file mode 100644
index 7a8d2f7..0000000
--- a/hubs/tests/views/test_widget.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from __future__ import unicode_literals
-
-
-from hubs.app import app
-from hubs.tests import APPTest, auth_set
-
-
-class WidgetTestCase(APPTest):
-
-    def test_source_name(self):
-        with auth_set(app, None):
-            url = '/source/about'
-            result = self.app.get(url)
-            self.assertEqual(result.status_code, 302)
-            expected_str = 'https://pagure.io/fedora-hubs/' \
-                           'blob/develop/f/hubs/widgets/about/__init__.py'
-            self.assertIn(expected_str, result.get_data(as_text=True))
-
-    def test_source_name_not_existent(self):
-        with auth_set(app, None):
-            url = '/source/notexistent'
-            result = self.app.get(url)
-            self.assertEqual(result.status_code, 404)
diff --git a/hubs/tests/widgets/test_about.py b/hubs/tests/widgets/test_about.py
index 1cb4ab1..703af25 100644
--- a/hubs/tests/widgets/test_about.py
+++ b/hubs/tests/widgets/test_about.py
@@ -1,25 +1,10 @@
 from __future__ import unicode_literals
 
-import json
-
-from hubs.tests import FakeAuthorization, widget_instance
 from . import WidgetTest
 
 
 class TestAbout(WidgetTest):
     plugin = 'about'  # The name in hubs.widgets.registry
 
-    def test_data_simple(self):
-        widget = widget_instance('ralph', self.plugin)
-        user = FakeAuthorization('ralph')
-        response = self.check_url('/ralph/%i/json' % widget.idx, user)
-        data = json.loads(response.get_data(as_text=True))
-        self.assertDictEqual(data['data'], {
-            'text': 'Testing.',
-            'title': "<span class='glyphicon glyphicon-info-sign' "
-                     "aria-hidden='true'></span> About",
-            'edit_mode': False,
-        })
-
     def test_view_authz(self):
         self._test_view_authz()
diff --git a/hubs/tests/widgets/test_badges.py b/hubs/tests/widgets/test_badges.py
index a984070..9151ed2 100644
--- a/hubs/tests/widgets/test_badges.py
+++ b/hubs/tests/widgets/test_badges.py
@@ -1,11 +1,9 @@
 from __future__ import unicode_literals
 
-import json
-
 from mock import Mock, patch
 
 import hubs.widgets
-from hubs.tests import FakeAuthorization, widget_instance
+from hubs.tests import widget_instance
 from . import WidgetTest
 
 
@@ -14,11 +12,9 @@ class TestBadges(WidgetTest):
 
     def test_data_simple(self):
         widget = widget_instance('ralph', self.plugin)
-        user = FakeAuthorization('ralph')
-        response = self.check_url('/ralph/%i/json' % widget.idx, user)
-        data = json.loads(response.get_data(as_text=True))
-        self.assertEquals(data['plugin'], 'badges')
-        self.assertIn('assertions', data['data'].keys())
+        func = widget.module.get_cached_functions()['GetBadges']
+        result = func(widget)()
+        self.assertIn('assertions', result.keys())
 
     def test_should_invalidate_wrong_topic(self):
         widget = widget_instance('ralph', self.plugin)
diff --git a/hubs/tests/widgets/test_contact.py b/hubs/tests/widgets/test_contact.py
index 7e06862..f011392 100644
--- a/hubs/tests/widgets/test_contact.py
+++ b/hubs/tests/widgets/test_contact.py
@@ -76,9 +76,10 @@ class ContactsTest(WidgetTest):
 
     def test_data_simple(self):
         user = FakeAuthorization('ralph')
-        response = self.check_url('/ralph/%i/json' % self.widget_idx, user)
-        data = json.loads(response.get_data(as_text=True))
-        self.assertDictEqual(data['data'], {
+        widget = Widget.query.get(self.widget_idx)
+        response = self.check_url(
+            '/ralph/w/contact/%i/' % self.widget_idx, user)
+        self.assertDictEqual(response.context, {
             'account_age': 'Oct 2010',
             'email': 'ralph@fedoraproject.org',
             'ircnick': 'ralph',
@@ -88,6 +89,8 @@ class ContactsTest(WidgetTest):
             'timezone': 'UTC',
             'usergroup': True,
             'edit_mode': False,
+            'widget': widget.module,
+            'widget_instance': widget,
         })
 
     def test_view_authz(self):
diff --git a/hubs/tests/widgets/test_fedmsgstats.py b/hubs/tests/widgets/test_fedmsgstats.py
index 2a4508a..91778b4 100644
--- a/hubs/tests/widgets/test_fedmsgstats.py
+++ b/hubs/tests/widgets/test_fedmsgstats.py
@@ -1,7 +1,5 @@
 from __future__ import unicode_literals
 
-import json
-
 from hubs.tests import FakeAuthorization, widget_instance
 from . import WidgetTest
 
@@ -12,9 +10,9 @@ class TestFedmsgStats(WidgetTest):
     def test_data_simple(self):
         widget = widget_instance('ralph', self.plugin)
         user = FakeAuthorization('ralph')
-        response = self.check_url('/ralph/%i/json' % widget.idx, user)
-        data = json.loads(response.get_data(as_text=True))
-        self.assertDictEqual(data['data'], {
+        response = self.check_url(
+            '/ralph/w/%s/%i/' % (self.plugin, widget.idx), user)
+        self.assertDictEqual(response.context, {
             'fedmsgs': 83854,
             'fedmsgs_text': '83,854',
             'subscribers': [],
@@ -22,9 +20,11 @@ class TestFedmsgStats(WidgetTest):
             'subscribers_text': '0',
             'subscribed_text': '0',
             'username': 'ralph',
-            'hub_subscribe_url': '/api/hub/ralph/subscribe',
-            'hub_unsubscribe_url': '/api/hub/ralph/unsubscribe',
+            'hub_subscribe_url': '/api/hubs/ralph/subscribe',
+            'hub_unsubscribe_url': '/api/hubs/ralph/unsubscribe',
             'edit_mode': False,
+            'widget': widget.module,
+            'widget_instance': widget,
         })
 
     def test_view_authz(self):
diff --git a/hubs/tests/widgets/test_halp.py b/hubs/tests/widgets/test_halp.py
index 2e33ade..5a489d3 100644
--- a/hubs/tests/widgets/test_halp.py
+++ b/hubs/tests/widgets/test_halp.py
@@ -117,57 +117,6 @@ class HalpViewsTestCase(WidgetTest):
     def test_view_authz(self):
         self._test_view_authz()
 
-    def test_json(self):
-        response = self.app.get('/ralph/%i/json' % self.widget.idx)
-        self.assertEqual(response.status_code, 200)
-        data = json.loads(response.get_data(as_text=True))
-        self.assertEquals(data['plugin'], 'halp')
-        self.assertIn('hubs', data['data'].keys())
-
-    def test_config_add(self):
-        user = FakeAuthorization('ralph')
-        response = self.check_url('/ralph/w/halp/add?position=right', user)
-        self.assertEqual(response.context["mode"], "add")
-        self.assertEqual(response.context["initial"], {
-            "hubs": ["ralph"], "per_page": 4,
-            })
-        self.assertEqual(
-            response.context["url"], "/ralph/add/halp?position=right")
-
-    def test_config_edit(self):
-        user = FakeAuthorization('ralph')
-        url = '/ralph/w/halp/%i/config' % self.widget.idx
-        response = self.check_url(url, user)
-        self.assertEqual(response.context["mode"], "edit")
-        self.assertEqual(response.context["initial"], {
-            'hubs': ['fedora-devel'],
-            'per_page': 3,
-            })
-        self.assertEqual(
-            response.context["url"], "/ralph/%i/edit" % self.widget.idx)
-
-    def test_config_authz(self):
-        urls = [
-            '/ralph/w/halp/add',
-            '/ralph/w/halp/%i/config' % self.widget.idx,
-            ]
-        # Anonymous
-        for url in urls:
-            self.check_url(url, None, 403)
-        # Logged-in
-        user = FakeAuthorization('decause')
-        for url in urls:
-            self.check_url(url, user, 403)
-        # Member
-        hub = Hub.query.get("ralph")
-        hub.subscribe(User.query.get("decause"), "member")
-        for url in urls:
-            self.check_url(url, user, 403)
-        # Owner
-        user = FakeAuthorization('ralph')
-        for url in urls:
-            self.check_url(url, user, 200)
-
     def test_config_hubs_suggest(self):
         response = self.app.get('/w/halp/hubs')
         self.assertEqual(response.status_code, 200)
diff --git a/hubs/tests/widgets/test_library.py b/hubs/tests/widgets/test_library.py
index 11e4c92..6cb8297 100644
--- a/hubs/tests/widgets/test_library.py
+++ b/hubs/tests/widgets/test_library.py
@@ -1,7 +1,5 @@
 from __future__ import unicode_literals
 
-import json
-
 from hubs.tests import FakeAuthorization, widget_instance
 from . import WidgetTest
 
@@ -12,27 +10,17 @@ class TestLibrary(WidgetTest):
     def test_data_simple(self):
         widget = widget_instance('ralph', self.plugin)
         user = FakeAuthorization('ralph')
-        response = self.check_url('/ralph/%i/json' % widget.idx, user)
-        data = json.loads(response.get_data(as_text=True))
+        response = self.check_url(
+            '/ralph/w/%s/%i/' % (self.plugin, widget.idx), user)
         expected_dict = {
-            "config": {
-                "urls": "ralph/"
-            },
-            "data": {
-                'title': 'Library',
-                "urls": [
-                    "<a href=\"ralph/\">ralph/</a>"
-                ],
-                'edit_mode': False,
-            },
-            "description": None,
-            "hub": "ralph",
-            "id": 52,
-            "index": 51,
-            "left": False,
-            "plugin": "library"
+            "urls": [
+                "<a href=\"ralph/\">ralph/</a>"
+            ],
+            'edit_mode': False,
+            'widget': widget.module,
+            'widget_instance': widget,
         }
-        self.assertDictEqual(data['data'], expected_dict['data'])
+        self.assertDictEqual(response.context, expected_dict)
 
     def test_view_authz(self):
         self._test_view_authz()
diff --git a/hubs/tests/widgets/test_meetings.py b/hubs/tests/widgets/test_meetings.py
index 0ac32a6..6536856 100644
--- a/hubs/tests/widgets/test_meetings.py
+++ b/hubs/tests/widgets/test_meetings.py
@@ -1,7 +1,5 @@
 from __future__ import unicode_literals
 
-import json
-
 from hubs.tests import FakeAuthorization, widget_instance
 from . import WidgetTest
 
@@ -13,10 +11,9 @@ class TestMeetings(WidgetTest):
         team = 'i18n'
         widget = widget_instance(team, self.plugin)
         user = FakeAuthorization('ralph')
-        response = self.check_url('/%s/%i/json' % (team, widget.idx), user)
-        data = json.loads(response.get_data(as_text=True))
-        calendar_name = data['data']['calendar']
-        self.assertIn(team, calendar_name)
+        response = self.check_url(
+            '/%s/w/%s/%i/' % (team, self.plugin, widget.idx), user)
+        self.assertIn(team, response.context["calendar"])
 
     def test_render_simple(self):
         team = 'i18n'
diff --git a/hubs/utils/views.py b/hubs/utils/views.py
index cc576c7..36df575 100644
--- a/hubs/utils/views.py
+++ b/hubs/utils/views.py
@@ -74,13 +74,14 @@ class WidgetConfigError(Exception):
     pass
 
 
-def create_widget_instance(hub, widget, position):
+def create_widget_instance(hub, widget, position, config=None):
     """View helper to instanciate a widget on a hub.
 
     Arguments:
         hub (hubs.models.Hub): Instance of the hub to create the widget in.
         widget (hubs.widgets.base.Widget): Widget to instanciate.
-        position (str): either ``left`` or ``right``.
+        position (str): Either ``left`` or ``right``.
+        config (dict): Initial configuration for the widget.
     """
     widget_instance = Widget(
         hub=hub, plugin=widget.name, index=-1,
@@ -88,28 +89,34 @@ def create_widget_instance(hub, widget, position):
     flask.g.db.add(widget_instance)
     flask.g.db.flush()  # will populate Widget.config
     try:
-        configure_widget_instance(widget_instance)
+        configure_widget_instance(widget_instance, config)
     except WidgetConfigError as e:
         flask.flash(e.args[0], "error")
     else:
         flask.flash("The widget has been added.")
+    # reorder widgets
+    reorder_widgets(hub, widget_instance.left)
+    return widget_instance
 
 
-def configure_widget_instance(widget_instance):
+def configure_widget_instance(widget_instance, widget_config):
     """Configure a widget instance using the request form data.
 
     Arguments:
         widget_instance (hubs.models.Widget): Instance of the widget that is to
             be configured.
+        widget_config (dict): Configuration to apply to the widget.
     """
+    if not widget_config:
+        return
     config = {}
     for param in widget_instance.module.get_parameters():
-        val = flask.request.form.get(param.name)
+        val = widget_config.get(param.name)
         if not val:
             raise WidgetConfigError(
                 'You must provide a value for: %s' % param.name)
         try:
-            config[param.name] = param.validator.from_string(val)
+            config[param.name] = param.validator(val)
         except ValueError as err:
             raise WidgetConfigError('Invalid data provided, error: %s' % err)
     # Updating in-place is not supported, it's a class property.
@@ -119,7 +126,7 @@ def configure_widget_instance(widget_instance):
     widget_instance.config = cur_config
     # Set the visibility parameter.
     try:
-        widget_instance.visibility = flask.request.form["visibility"]
+        widget_instance.visibility = widget_config["visibility"]
     except KeyError:
         pass  # the value is optional.
     # Update the hub's mtime.  TODO: use a SQLAlchemy signal here.
@@ -136,6 +143,27 @@ def configure_widget_instance(widget_instance):
             'if the error persists, please warn an admin')
 
 
+def reorder_widgets(hub, is_left):
+    """Reset widget instances indicies for a hub and a column."""
+    all_widgets = [w for w in hub.widgets if w.left == is_left]
+    for index, widget in enumerate(all_widgets):
+        widget.index = index
+
+
+def move_widget(widget_instance, new_index):
+    """Move a widget instance to a new index in the same column."""
+    all_widgets = [
+        w for w in widget_instance.hub.widgets
+        if w.left == widget_instance.left
+        ]
+    old_index = [w.idx for w in all_widgets].index(widget_instance.idx)
+    # Move the widget in the list
+    all_widgets.insert(new_index, all_widgets.pop(old_index))
+    # Apply indices according to the position in the list
+    for index, widget in enumerate(all_widgets):
+        widget.index = index
+
+
 def authenticated():
     """ Utility function checking if the current auth is set or not."""
     return hasattr(flask.g, 'auth') \
@@ -158,33 +186,43 @@ def login_required(function):
     return decorated_function
 
 
+def check_hub_access(hub, action, json=False):
+    """Check access to the hub for the specified action.
+
+    Raises:
+        :py:class:`werkzeug.exceptions.Forbidden`: The action is denied (403).
+    """
+    try:
+        user = flask.g.user
+    except AttributeError:
+        user = None
+    if not hub.allows(user, action):
+        if action == "view":
+            msg = "This hub is for members only."
+        elif action == "config":
+            msg = "You are not allowed to configure this hub."
+        else:
+            msg = "Access forbidden."
+        if json:
+            response = flask.jsonify({
+                "status": "ERROR", "message": msg, "hub": hub.name,
+                })
+        else:
+            response = flask.render_template(
+                'errors/hub.html', hub=hub, msg=msg,
+                )
+        flask.abort(403, response=flask.make_response(response, 403))
+    return True
+
+
 def require_hub_access(action, url_param="name", json=False):
-    """Check access to the hub for the specified action."""
+    """View decorator to check access to the hub for the specified action."""
     def decorator(function):
         @functools.wraps(function)
         def wrapper(*args, **kwargs):
             hub_name = kwargs[url_param]
             hub = get_hub(hub_name, load_config=True)
-            try:
-                user = flask.g.user
-            except AttributeError:
-                user = None
-            if not hub.allows(user, action):
-                if action == "view":
-                    msg = "This hub is for members only."
-                elif action == "config":
-                    msg = "You are not allowed to configure this hub."
-                else:
-                    msg = "Access forbidden."
-                if json:
-                    result = flask.jsonify({
-                        "status": "ERROR", "message": msg, "hub": hub.name,
-                        })
-                else:
-                    result = flask.render_template(
-                        'errors/hub.html', hub=hub, msg=msg,
-                        )
-                return result, 403
+            check_hub_access(hub, action, json)
             return function(*args, **kwargs)
         return wrapper
     return decorator
diff --git a/hubs/views/__init__.py b/hubs/views/__init__.py
index 33b68f2..4ea49ec 100644
--- a/hubs/views/__init__.py
+++ b/hubs/views/__init__.py
@@ -4,6 +4,5 @@ from __future__ import absolute_import
 
 from .root import *
 from .hub import *
-from .widget import *
 from .user import *
 from .api import *
diff --git a/hubs/views/api.py b/hubs/views/api.py
deleted file mode 100644
index b1232a5..0000000
--- a/hubs/views/api.py
+++ /dev/null
@@ -1,77 +0,0 @@
-from __future__ import unicode_literals, absolute_import
-
-
-import flask
-import hubs.models
-
-from hubs.app import app
-from hubs.utils.views import get_hub, login_required
-
-
-@app.route('/api/hub/<hub>/subscribe', methods=['POST'])
-@login_required
-def hub_subscribe(hub):
-    hub = get_hub(hub)
-    user = hubs.models.User.by_username(flask.g.auth.nickname)
-    hub.subscribe(user)
-    flask.g.db.commit()
-    return flask.redirect(flask.url_for('hub', name=hub.name))
-
-
-@app.route('/api/hub/<hub>/unsubscribe', methods=['POST'])
-@login_required
-def hub_unsubscribe(hub):
-    hub = get_hub(hub)
-    user = hubs.models.User.by_username(flask.g.auth.nickname)
-    try:
-        hub.unsubscribe(user)
-    except KeyError:
-        return flask.abort(400)
-    flask.g.db.commit()
-    return flask.redirect(flask.url_for('hub', name=hub.name))
-
-
-@app.route('/api/hub/<hub>/star', methods=['POST'])
-@login_required
-def hub_star(hub):
-    hub = get_hub(hub)
-    user = hubs.models.User.by_username(flask.g.auth.nickname)
-    hub.subscribe(user, role='stargazer')
-    flask.g.db.commit()
-    return flask.redirect(flask.url_for('hub', name=hub.name))
-
-
-@app.route('/api/hub/<hub>/unstar', methods=['POST'])
-@login_required
-def hub_unstar(hub):
-    hub = get_hub(hub)
-    user = hubs.models.User.by_username(flask.g.auth.nickname)
-    try:
-        hub.unsubscribe(user, role='stargazer')
-    except KeyError:
-        return flask.abort(400)
-    flask.g.db.commit()
-    return flask.redirect(flask.url_for('hub', name=hub.name))
-
-
-@app.route('/api/hub/<hub>/join', methods=['POST'])
-@login_required
-def hub_join(hub):
-    hub = get_hub(hub)
-    user = hubs.models.User.by_username(flask.g.auth.nickname)
-    hub.subscribe(user, role='member')
-    flask.g.db.commit()
-    return flask.redirect(flask.url_for('hub', name=hub.name))
-
-
-@app.route('/api/hub/<hub>/leave', methods=['POST'])
-@login_required
-def hub_leave(hub):
-    hub = get_hub(hub)
-    user = hubs.models.User.by_username(flask.g.auth.nickname)
-    try:
-        hub.unsubscribe(user, role='member')
-    except KeyError:
-        return flask.abort(400)
-    flask.g.db.commit()
-    return flask.redirect(flask.url_for('hub', name=hub.name))
diff --git a/hubs/views/api/__init__.py b/hubs/views/api/__init__.py
new file mode 100644
index 0000000..5375623
--- /dev/null
+++ b/hubs/views/api/__init__.py
@@ -0,0 +1,7 @@
+from __future__ import absolute_import
+
+# flake8: noqa
+
+from .hub_association import *
+from .hub_config import *
+from .hub_widget import *
diff --git a/hubs/views/api/hub_association.py b/hubs/views/api/hub_association.py
new file mode 100644
index 0000000..9b5e8ba
--- /dev/null
+++ b/hubs/views/api/hub_association.py
@@ -0,0 +1,80 @@
+from __future__ import absolute_import
+
+import logging
+
+import flask
+
+import hubs.models
+from hubs.app import app
+from hubs.utils.views import get_hub, login_required
+
+log = logging.getLogger(__name__)
+
+
+@app.route('/api/hubs/<hub>/subscribe', methods=['POST'])
+@login_required
+def api_hub_subscribe(hub):
+    hub = get_hub(hub)
+    user = hubs.models.User.by_username(flask.g.auth.nickname)
+    hub.subscribe(user)
+    flask.g.db.commit()
+    return flask.redirect(flask.url_for('hub', name=hub.name))
+
+
+@app.route('/api/hubs/<hub>/unsubscribe', methods=['POST'])
+@login_required
+def api_hub_unsubscribe(hub):
+    hub = get_hub(hub)
+    user = hubs.models.User.by_username(flask.g.auth.nickname)
+    try:
+        hub.unsubscribe(user)
+    except KeyError:
+        return flask.abort(400)
+    flask.g.db.commit()
+    return flask.redirect(flask.url_for('hub', name=hub.name))
+
+
+@app.route('/api/hubs/<hub>/star', methods=['POST'])
+@login_required
+def api_hub_star(hub):
+    hub = get_hub(hub)
+    user = hubs.models.User.by_username(flask.g.auth.nickname)
+    hub.subscribe(user, role='stargazer')
+    flask.g.db.commit()
+    return flask.redirect(flask.url_for('hub', name=hub.name))
+
+
+@app.route('/api/hubs/<hub>/unstar', methods=['POST'])
+@login_required
+def api_hub_unstar(hub):
+    hub = get_hub(hub)
+    user = hubs.models.User.by_username(flask.g.auth.nickname)
+    try:
+        hub.unsubscribe(user, role='stargazer')
+    except KeyError:
+        return flask.abort(400)
+    flask.g.db.commit()
+    return flask.redirect(flask.url_for('hub', name=hub.name))
+
+
+@app.route('/api/hubs/<hub>/join', methods=['POST'])
+@login_required
+def api_hub_join(hub):
+    hub = get_hub(hub)
+    user = hubs.models.User.by_username(flask.g.auth.nickname)
+    hub.subscribe(user, role='member')
+    flask.g.db.commit()
+    return flask.redirect(flask.url_for('hub', name=hub.name))
+
+
+@app.route('/api/hubs/<hub>/leave', methods=['POST'])
+@login_required
+def api_hub_leave(hub):
+    hub = get_hub(hub)
+    user = hubs.models.User.by_username(flask.g.auth.nickname)
+    try:
+        hub.unsubscribe(user, role='member')
+    except KeyError:
+        return flask.abort(400)
+    flask.g.db.commit()
+    return flask.redirect(flask.url_for('hub', name=hub.name))
diff --git a/hubs/views/api/hub_config.py b/hubs/views/api/hub_config.py
new file mode 100644
index 0000000..fa76e29
--- /dev/null
+++ b/hubs/views/api/hub_config.py
@@ -0,0 +1,144 @@
+from __future__ import absolute_import
+
+import logging
+
+import flask
+
+import hubs.models
+from hubs.app import app
+from hubs.utils.views import (
+    get_hub, check_hub_access, RequestValidator, require_hub_access,
+    )
+
+log = logging.getLogger(__name__)
+
+
+class ConfigChangeError(Exception):
+    pass
+
+
+@app.route('/api/hubs/<name>/config', methods=['GET', 'PUT'])
+@require_hub_access("view", json=True)
+def api_hub_config(name):
+    hub = get_hub(name)
+    if flask.request.method == 'PUT':
+        check_hub_access(hub, "config", json=True)
+        request_data = flask.request.get_json()
+        if request_data is None:
+            return flask.jsonify({
+                "status": "ERROR",
+                "message": "No configuration change provided"
+                })
+        try:
+            if request_data.get("config"):
+                result = hub_config_put_config(hub, request_data["config"])
+            if request_data.get("users"):
+                result = hub_config_put_users(hub, request_data["users"])
+        except ConfigChangeError as e:
+            result = e.args[0]
+        else:
+            result = {"status": "OK"}
+        try:
+            flask.g.db.commit()
+        except Exception as err:
+            result = {"status": "ERROR", "message": str(err)}
+        return flask.jsonify(result)
+    result = {"status": "OK", "data": hub.get_props()}
+    return flask.jsonify(result)
+
+
+def hub_config_put_config(hub, config):
+    # Validate values
+    def _validate_chat_domain(value):
+        valid_chat_domains = [
+            network["domain"] for network in app.config["CHAT_NETWORKS"]
+            ]
+        if not value and len(valid_chat_domains) > 0:
+            value = valid_chat_domains[0]
+        if value not in valid_chat_domains:
+            raise ValueError("Unsupported chat domain.")
+        return value
+
+    validator = RequestValidator(dict(
+        # Only allow the parameters listed in __json__().
+        (key, None) for key in hub.config.__json__().keys()
+    ))
+    validator.converters["chat_domain"] = _validate_chat_domain
+    try:
+        values = validator(config)
+    except ValueError as e:
+        result = {"status": "ERROR", "message": "Invalid value(s)"}
+        result["fields"] = e.args[0]
+        raise ConfigChangeError(result)
+
+    # Now set the configuration values.
+    for key, value in values.items():
+        setattr(hub.config, key, value)
+
+
+def hub_config_put_users(hub, user_roles):
+    new_associations = []
+    for role in user_roles:
+        for u in user_roles[role]:
+            new_associations.append((u["username"], role))
+    # Remove old associations
+    for assoc in hub.associations:
+        if assoc.hub != hub:
+            continue
+        try:
+            new_associations.remove(
+                (assoc.user.username, assoc.role))
+        except ValueError:
+            hub.associations.remove(assoc)
+    # Add new associations
+    for assoc in new_associations:
+        username, role = assoc
+        user = hubs.models.User.get(username=username)
+        if user is None:
+            result = {
+                "status": "ERROR",
+                "fields": {"username": "Unknown user"},
+                "message": "Unknown user {}".format(u["username"]),
+                }
+            raise ConfigChangeError(result)
+        hub.associations.append(hubs.models.Association(
+            hub=hub, user=user, role=role))
+    flask.g.db.commit()
+
+
+@app.route('/api/hubs/<name>/config/suggest-users')
+@require_hub_access("config", json=True)
+def api_hub_config_suggest_users(name):
+    MAX_SUGGESTS = 10
+    hub = get_hub(name)
+    results = flask.g.db.query(
+        hubs.models.User.username,
+        hubs.models.User.fullname,
+        )
+    query = flask.request.args.get("q")
+    if query:
+        results = results.filter(
+            hubs.models.User.username.ilike("%{}%".format(query))
+        )
+    exclude = flask.request.args.get("exclude-role")
+    if exclude:
+        exclude_query = flask.g.db.query(
+                hubs.models.User.username
+            ).join(
+                hubs.models.Association
+            ).join(
+                hubs.models.Hub
+            ).filter(
+                hubs.models.Hub.name == hub.name,
+                hubs.models.Association.role == exclude,
+            )
+        results = results.filter(
+            ~hubs.models.User.username.in_(exclude_query)
+            )
+    results = results.order_by(hubs.models.User.username).limit(MAX_SUGGESTS)
+    return flask.jsonify({
+        "status": "OK",
+        "data": [
+            {"username": r[0], "fullname": r[1]} for r in results
+            ],
+        })
diff --git a/hubs/views/api/hub_widget.py b/hubs/views/api/hub_widget.py
new file mode 100644
index 0000000..3ef777d
--- /dev/null
+++ b/hubs/views/api/hub_widget.py
@@ -0,0 +1,111 @@
+from __future__ import absolute_import
+
+import logging
+
+import flask
+from hubs.app import app
+from hubs.widgets import registry
+from hubs.utils.views import (
+    create_widget_instance, configure_widget_instance, get_hub,
+    get_widget_instance, WidgetConfigError,
+    check_hub_access, require_hub_access,
+    move_widget, reorder_widgets,
+    )
+
+log = logging.getLogger(__name__)
+
+
+@app.route('/api/widgets/', methods=['GET'])
+def api_widgets():
+    widgets = []
+    for widget in registry.values():
+        widgets.append(widget.get_props(None))
+    return flask.jsonify({"status": "OK", "data": widgets})
+
+
+@app.route('/api/hubs/<hub>/widgets/', methods=['GET', 'POST'])
+@require_hub_access("view", url_param="hub", json=True)
+def api_hub_widgets(hub):
+    hub = get_hub(hub)
+    try:
+        user = flask.g.user
+    except AttributeError:
+        user = None
+    if flask.request.method == 'POST':
+        check_hub_access(hub, "config", json=True)
+        request_data = flask.request.get_json()
+        try:
+            widget_name = request_data["name"]
+            widget_config = request_data["config"]
+            position = request_data["position"]
+        except KeyError:
+            flask.abort(400, 'Invalid request sent')
+        if position not in ["left", "right"]:
+            flask.abort(400, 'Invalid request sent')
+        available_widgets = [
+            widget.name
+            for widget in registry.values()
+            if widget.position in ['both', position]
+            ]
+        if widget_name not in available_widgets:
+            flask.abort(400, 'Unknown widget')
+        widget = registry[widget_name]
+        create_widget_instance(hub, widget, position, widget_config)
+        flask.g.db.commit()
+        return flask.jsonify({"status": "OK"})
+    user_can_config = hub.allows(user, "config")
+    widgets = []
+    for widget_instance in hub.widgets:
+        if not widget_instance.enabled:
+            continue
+        if not widget_instance.allows(user, "view"):
+            continue
+        widgets.append(widget_instance.get_props(
+            with_secret_config=user_can_config))
+    return flask.jsonify({"status": "OK", "data": widgets})
+
+
+@app.route('/api/hubs/<hub>/widgets/<int:idx>/',
+           methods=['GET', 'PUT', 'DELETE'])
+@require_hub_access("view", url_param="hub", json=True)
+def api_hub_widget(hub, idx):
+    widget_instance = get_widget_instance(hub, idx)
+    hub = widget_instance.hub
+    try:
+        user = flask.g.user
+    except AttributeError:
+        user = None
+    if flask.request.method == "GET":
+        user_can_config = hub.allows(user, "config")
+        result = widget_instance.get_props(with_secret_config=user_can_config)
+        return flask.jsonify({"status": "OK", "data": result})
+    elif flask.request.method == "PUT":
+        check_hub_access(hub, "config", json=True)
+        request_data = flask.request.get_json()
+        widget_config = request_data.get("config", {})
+        try:
+            configure_widget_instance(widget_instance, widget_config)
+        except WidgetConfigError as e:
+            return flask.jsonify({"status": "ERROR", "message": e.args[0]})
+        if "index" in request_data:
+            # Reorder the widgets
+            move_widget(widget_instance, request_data["index"])
+        try:
+            flask.g.db.commit()
+        except Exception as e:
+            msg = "Could not configure this widget: {}".format(e)
+            return flask.jsonify({"status": "ERROR", "message": msg})
+        return flask.jsonify({"status": "OK"})
+    elif flask.request.method == "DELETE":
+        # Remove the widget from the hub.
+        check_hub_access(hub, "config", json=True)
+        is_left = widget_instance.left
+        flask.g.db.delete(widget_instance)
+        try:
+            flask.g.db.commit()
+        except Exception as e:
+            msg = "Could not remove this widget: {}".format(e)
+            return flask.jsonify({"status": "ERROR", "message": msg})
+        reorder_widgets(hub, is_left)
+        return flask.jsonify({"status": "OK"})
+    flask.abort(400)
diff --git a/hubs/views/hub.py b/hubs/views/hub.py
index ff52725..6677a7e 100644
--- a/hubs/views/hub.py
+++ b/hubs/views/hub.py
@@ -1,13 +1,11 @@
 from __future__ import unicode_literals, absolute_import
 
-import datetime
 import flask
 import hubs.models
 
 from hubs.app import app
 from hubs.utils.views import (
-    get_hub, get_sse_url, get_visible_widgets, login_required,
-    RequestValidator, require_hub_access,
+    get_hub, get_sse_url, get_menu_entries, get_visible_widgets, require_hub_access,
     )
 
 
@@ -24,269 +22,3 @@ def hub(name):
         edit=False,
         sse_url=get_sse_url("hub/{}".format(hub.name)),
         )
-
-
-@app.route('/<name>/json/')
-@app.route('/<name>/json')
-@require_hub_access("view", json=True)
-def hub_json(name):
-    hub = get_hub(name)
-    response = flask.jsonify(hub.__json__())
-    # TODO -- modify headers with response.headers['X-fedora-hubs-wat'] = 'foo'
-    return response
-
-
-@app.route('/<name>/edit', methods=['GET', 'POST'])
-@login_required
-@require_hub_access("config")
-def hub_edit(name):
-    hub = get_hub(name, load_config=True)
-    if flask.request.method == 'POST':
-        return hub_edit_post(hub)
-    else:
-        return hub_edit_get(hub)
-
-
-def hub_edit_get(hub):
-    widgets = get_visible_widgets(hub)
-    return flask.render_template(
-        'hubs.html',
-        hub=hub,
-        widgets=widgets,
-        edit=True,
-        sse_url=get_sse_url("hub/{}".format(hub.name)),
-        )
-
-
-def hub_edit_post(hub):
-    is_js = flask.request.form.get('js', False)
-
-    error = False
-
-    # Check the input submitted
-    # Right side
-    r_widget_ids = [
-        w.strip().replace('widget-', '')
-        for w in flask.request.form.getlist('right_widgets[]')
-        if w.strip()
-        ]
-    try:
-        r_widget_ids = [int(w) for w in r_widget_ids]
-    except:
-        flask.flash('Invalid widget identifiers submitted', 'error')
-        error = True
-
-    r_indexes = [
-        i.strip() for i in flask.request.form.getlist('right_indexes[]')
-        if i.strip()
-        ]
-
-    try:
-        r_indexes = [int(i) for i in r_indexes]
-    except:
-        if not is_js:
-            flask.flash('Invalid indexes submitted', 'error')
-        error = True
-
-    if len(r_widget_ids) != len(r_indexes):
-        if not is_js:
-            flask.flash(
-                'The number of indexes and the number of widgets are not of '
-                'the same length', 'error')
-        error = True
-
-    # Left side
-    l_widget_ids = [
-        w.strip().replace('widget-', '')
-        for w in flask.request.form.getlist('left_widgets[]')
-        if w.strip()
-        ]
-    try:
-        l_widget_ids = [int(w) for w in l_widget_ids]
-    except:
-        flask.flash('Invalid widget identifiers submitted', 'error')
-        error = True
-
-    l_indexes = [
-        i.strip() for i in flask.request.form.getlist('left_indexes[]')
-        if i.strip()
-        ]
-
-    try:
-        l_indexes = [int(i) for i in l_indexes]
-    except:
-        if not is_js:
-            flask.flash('Invalid indexes submitted', 'error')
-        error = True
-
-    if len(l_widget_ids) != len(l_indexes):
-        if not is_js:
-            flask.flash(
-                'The number of indexes and the number of widgets are not of '
-                'the same length', 'error')
-        error = True
-
-    # If all good, update the database
-    if not error:
-        for cnt, wid in enumerate(r_widget_ids):
-            widget = flask.g.db.query(hubs.models.Widget).get(wid)
-            if widget.index != r_indexes[cnt]:
-                widget.index = r_indexes[cnt]
-                flask.g.db.add(widget)
-        for cnt, wid in enumerate(l_widget_ids):
-            widget = flask.g.db.query(hubs.models.Widget).get(wid)
-            if widget.index != l_indexes[cnt]:
-                widget.index = l_indexes[cnt]
-                flask.g.db.add(widget)
-        hub.last_edited = datetime.datetime.utcnow()
-        flask.g.db.add(hub)
-        try:
-            flask.g.db.commit()
-        except Exception as err:
-            error = True
-            if not is_js:
-                flask.flash(
-                    'Could not save your changes to the database '
-                    'if the error persists, please warn an admin',
-                    'error')
-    if is_js:
-        if error:
-            flask.abort(400)
-        else:
-            return ('ok', 200)
-    else:
-        return flask.redirect(flask.url_for('hub', name=hub.name))
-
-
-@app.route('/<name>/config', methods=['GET', 'POST'])
-@login_required
-@require_hub_access("config", json=True)
-def hub_config(name):
-    hub = get_hub(name)
-    if flask.request.method == 'POST':
-        category = flask.request.args["category"]
-        if category == "config":
-            result = hub_config_post_config(hub)
-        if category == "role_change":
-            result = hub_config_post_role_change(hub)
-        if result is not None:
-            return result
-    result = hub_config_get(hub)
-    return flask.jsonify(result)
-
-
-def hub_config_get(hub):
-    roles = ["owner", "member"]
-    result = {
-        "status": "OK",
-        "result": {
-            "hubconfig": hub.config.__json__(),
-            "general": {
-                "chat_networks": app.config["CHAT_NETWORKS"],
-                "hub_visibility": hubs.models.HubConfig.VISIBILITY,
-                "roles": roles,
-            },
-            "users": {role: [] for role in roles},
-        },
-    }
-    for assoc in sorted(hub.associations, key=lambda a: a.user.username):
-        if assoc.role not in roles:
-            continue
-        user = {
-            "username": assoc.user.username,
-            "fullname": assoc.user.fullname,
-            "role": assoc.role,
-        }
-        if (assoc.role == "owner" and
-                assoc.user.username == flask.g.user.username):
-            user["locked"] = True
-        result["result"]["users"][assoc.role].append(user)
-    return result
-
-
-def hub_config_post_config(hub):
-    # Validate values
-    def _validate_chat_domain(value):
-        valid_chat_domains = [
-            network["domain"] for network in app.config["CHAT_NETWORKS"]
-            ]
-        if not value and len(valid_chat_domains) > 0:
-            value = valid_chat_domains[0]
-        if value not in valid_chat_domains:
-            raise ValueError("Unsupported chat domain.")
-        return value
-
-    validator = RequestValidator(dict(
-        # Only allow the parameters listed in __json__().
-        (key, None) for key in hub.config.__json__().keys()
-    ))
-    validator.converters["chat_domain"] = _validate_chat_domain
-    try:
-        values = validator(flask.request.form)
-    except ValueError as e:
-        result = {"status": "ERROR", "message": "Invalid value(s)"}
-        result["fields"] = e.args[0]
-        return flask.jsonify(result)
-
-    # Now set the configuration values
-    for key, value in values.items():
-        setattr(hub.config, key, value)
-    try:
-        flask.g.db.commit()
-    except Exception as err:
-        result = {"status": "ERROR", "message": str(err)}
-        return flask.jsonify(result)
-
-
-def hub_config_post_role_change(hub):
-    username = flask.request.form["username"]
-    user = hubs.models.User.get(username=username)
-    if user is None:
-        result = {
-            "status": "ERROR",
-            "fields": {"username": "Unknown user"},
-            "message": "Unknown user",
-            }
-        return flask.jsonify(result)
-    role = flask.request.form.get("role")
-    for assoc in hub.associations:
-        if assoc.hub == hub and assoc.user == user:
-            hub.associations.remove(assoc)
-    if role:
-        hub.associations.append(hubs.models.Association(
-            hub=hub, user=user, role=role))
-    flask.g.db.commit()
-
-
-@app.route('/<name>/config/suggest-users')
-@login_required
-@require_hub_access("config", json=True)
-def hub_config_suggest_users(name):
-    MAX_SUGGESTS = 10
-    hub = get_hub(name)
-    results = flask.g.db.query(hubs.models.User.username)
-    query = flask.request.args.get("q")
-    if query:
-        results = results.filter(
-            hubs.models.User.username.ilike("%{}%".format(query))
-        )
-    exclude = flask.request.args.get("exclude-role")
-    if exclude:
-        exclude_query = flask.g.db.query(
-                hubs.models.User.username
-            ).join(
-                hubs.models.Association
-            ).join(
-                hubs.models.Hub
-            ).filter(
-                hubs.models.Hub.name == hub.name,
-                hubs.models.Association.role == exclude,
-            )
-        results = results.filter(
-            ~hubs.models.User.username.in_(exclude_query)
-            )
-    results = results.order_by(hubs.models.User.username).limit(MAX_SUGGESTS)
-    return flask.jsonify({
-        "status": "OK",
-        "results": [r[0] for r in results],
-        })
diff --git a/hubs/views/widget.py b/hubs/views/widget.py
deleted file mode 100644
index 2cd7f98..0000000
--- a/hubs/views/widget.py
+++ /dev/null
@@ -1,130 +0,0 @@
-from __future__ import unicode_literals, absolute_import
-
-import flask
-
-from hubs.app import app
-from hubs.widgets import registry
-from pkg_resources import resource_isdir
-from hubs.utils.views import (
-    create_widget_instance, configure_widget_instance, get_hub,
-    get_widget_instance, login_required, WidgetConfigError,
-    get_position, require_hub_access,
-    )
-
-
-@app.route('/<name>/add', methods=['GET', 'POST'])
-@login_required
-@require_hub_access("config")
-def hub_add_widget(name):
-    hub = get_hub(name)
-    position = get_position()
-    widgets = [
-        widget
-        for widget in registry.values()
-        if widget.position in ['both', position]
-        ]
-
-    if flask.request.method == 'POST':
-        widget_name = flask.request.form.get('widget')
-        if not widget_name:
-            flask.abort(400, 'Invalid request sent')
-        if widget_name not in [w.name for w in widgets]:
-            flask.abort(404, 'Unknown widget called')
-        widget = registry[widget_name]
-        if widget.get_parameters():
-            return flask.jsonify({
-                "status": "CONFIGURE",
-                "url": widget.get_add_url(hub, position),
-                })
-        create_widget_instance(hub, widget, position)
-        return flask.jsonify({"status": "ADDED"})
-    return flask.render_template(
-        'add_widget.html',
-        hub=hub,
-        widgets=widgets,
-        position=position,
-    )
-
-
-@app.route('/<hub>/add/<widget>', methods=['GET', 'POST'])
-@login_required
-@require_hub_access("config", url_param="hub")
-def widget_add(hub, widget):
-    hub = get_hub(hub)
-    position = get_position()
-    try:
-        widget = registry[widget]
-    except KeyError:
-        flask.abort(404, 'Unknown widget: %s' % widget)
-    if flask.request.method == 'POST':
-        create_widget_instance(hub, widget, position)
-        return flask.jsonify({"status": "ADDED"})
-    return flask.render_template(
-        'widget_add.html',
-        hub=hub,
-        widget=widget,
-        position=position,
-    )
-
-
-@app.route('/<hub>/<int:idx>/edit', methods=['GET', 'POST'])
-@login_required
-@require_hub_access("config", url_param="hub")
-def widget_edit(hub, idx):
-    widget_instance = get_widget_instance(hub, idx)
-    if flask.request.method == 'POST':
-        try:
-            configure_widget_instance(widget_instance)
-        except WidgetConfigError as e:
-            flask.flash(e.args[0], "error")
-        else:
-            flask.flash("The widget has been updated.")
-        return flask.redirect(flask.url_for('hub_edit', name=hub))
-    else:
-        return flask.render_template(
-            'widget_edit.html',
-            hub=widget_instance.hub,
-            widget_instance=widget_instance,
-        )
-
-
-@app.route('/<hub>/<int:idx>/delete', methods=['POST'])
-@login_required
-@require_hub_access("config", url_param="hub")
-def widget_edit_delete(hub, idx):
-    ''' Remove a widget from a hub. '''
-    widget_instance = get_widget_instance(hub, idx)
-    flask.g.db.delete(widget_instance)
-    try:
-        flask.g.db.commit()
-    except Exception:
-        flask.flash(
-            'Could not delete this widget from this hub in the database '
-            'if the error persists, please warn an admin',
-            'error')
-    return flask.redirect(flask.url_for('hub_edit', name=hub))
-
-
-@app.route('/<hub>/<int:idx>/json')
-@require_hub_access("view", url_param="hub")
-def widget_json(hub, idx):
-    widget = get_widget_instance(hub, idx)
-    response = flask.jsonify(widget.__json__())
-    # TODO -- modify headers with response.headers['X-fedora-hubs-wat'] = 'foo'
-    return response
-
-
-@app.route('/source/<name>')
-def widget_source(name):
-    try:
-        widget_path = registry[name].__module__
-    except KeyError:
-        flask.abort(404)
-    widget_url = widget_path.replace(".", "/")
-    parent, _ignore, module = widget_path.rpartition(".")
-    if resource_isdir(parent, module):
-        widget_url += "/__init__.py"
-    else:
-        widget_url += ".py"
-    url = "/".join([app.config.get('SOURCE_URL'), widget_url])
-    return flask.redirect(url)
diff --git a/hubs/widgets/fedmsgstats/__init__.py b/hubs/widgets/fedmsgstats/__init__.py
index 2cecb75..62fe099 100644
--- a/hubs/widgets/fedmsgstats/__init__.py
+++ b/hubs/widgets/fedmsgstats/__init__.py
@@ -37,9 +37,9 @@ class BaseView(RootWidgetView):
         context = dict(
             username=username,
             hub_subscribe_url=flask.url_for(
-                'hub_subscribe', hub=instance.hub.name),
+                'api_hub_subscribe', hub=instance.hub.name),
             hub_unsubscribe_url=flask.url_for(
-                'hub_unsubscribe', hub=instance.hub.name),
+                'api_hub_unsubscribe', hub=instance.hub.name),
         )
         get_stats = GetStats(instance)
         context.update(get_stats())
diff --git a/hubs/widgets/stats/__init__.py b/hubs/widgets/stats/__init__.py
index b893feb..589d0ce 100644
--- a/hubs/widgets/stats/__init__.py
+++ b/hubs/widgets/stats/__init__.py
@@ -41,13 +41,14 @@ class GetStats(CachedFunction):
             subscribers_text=commas(len(subscribers)),
             stargazers_text=commas(len(stargazers)),
 
-            hub_leave_url=flask.url_for('hub_leave', hub=hub.name),
-            hub_join_url=flask.url_for('hub_join', hub=hub.name),
-            hub_unstar_url=flask.url_for('hub_unstar', hub=hub.name),
-            hub_star_url=flask.url_for('hub_star', hub=hub.name),
-            hub_subscribe_url=flask.url_for('hub_subscribe', hub=hub.name),
+            hub_leave_url=flask.url_for('api_hub_leave', hub=hub.name),
+            hub_join_url=flask.url_for('api_hub_join', hub=hub.name),
+            hub_unstar_url=flask.url_for('api_hub_unstar', hub=hub.name),
+            hub_star_url=flask.url_for('api_hub_star', hub=hub.name),
+            hub_subscribe_url=flask.url_for(
+                'api_hub_subscribe', hub=hub.name),
             hub_unsubscribe_url=flask.url_for(
-                'hub_unsubscribe', hub=hub.name),
+                'api_hub_unsubscribe', hub=hub.name),
         )
 
     def should_invalidate(self, message):

From 1389bb26b55cf2bb05b252f494489dd252d7b5df Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Sep 28 2017 17:35:09 +0000
Subject: [PATCH 9/20] Widget add and config panels are now React-based


---

diff --git a/hubs/models.py b/hubs/models.py
index 498d4fa..55530f9 100644
--- a/hubs/models.py
+++ b/hubs/models.py
@@ -458,9 +458,6 @@ class Widget(ObjectAuthzMixin, BASE):
     def module(self):
         return hubs.widgets.registry[self.plugin]
 
-    @property
-    def edit_url(self):
-        return self.module.get_edit_url(self.hub, self)
 
     @property
     def enabled(self):
diff --git a/hubs/templates/add_widget.html b/hubs/templates/add_widget.html
deleted file mode 100644
index 878c2fd..0000000
--- a/hubs/templates/add_widget.html
+++ /dev/null
@@ -1,30 +0,0 @@
-
-<div class="modal-dialog" id="adding_form">
-  <div class="modal-content">
-    <form method="post" action="{{ url_for('hub_add_widget', name=hub.name) }}">
-    <input type="hidden" name="position" value="{{ position }}" />
-    <!-- Modal content-->
-      <div class="modal-header">
-        <button type="button" class="close" data-dismiss="modal">&times;</button>
-        <h4 class="modal-title">
-          Adding a widget to hub: {{ hub.name }}
-        </h4>
-      </div>
-      <div class="modal-body">
-        <select id="widget" class="c-select" name="widget">
-            {% for widget in widgets %}
-            <option value="{{ widget.name }}">{{ widget.label }}</option>
-            {% endfor %}
-        </select>
-      </div>
-      <div class="modal-footer">
-        <button type="button" class="btn btn-secondary" data-dismiss="modal">
-          Close
-        </button>
-        <button type="submit" class="btn btn-primary" id="submit_btn">
-          Add
-        </button>
-      </div>
-    </form>
-  </div>
-</div>
diff --git a/hubs/templates/widget_add.html b/hubs/templates/widget_add.html
deleted file mode 100644
index d3cf376..0000000
--- a/hubs/templates/widget_add.html
+++ /dev/null
@@ -1,49 +0,0 @@
-<div class="modal-dialog widget-add">
-  <div class="modal-content">
-    <form method="post" action="{{ url_for('widget_add', hub=hub.name, widget=widget.name) }}">
-      <input type="hidden" name="position" value="{{ position }}" />
-      <!-- Modal content-->
-      <div class="modal-header">
-        <button type="button" class="close" data-dismiss="modal">&times;</button>
-        <h4 class="modal-title">
-            Adding widget "{{ widget.name }}" to hub {{ hub.name }}
-        </h4>
-      </div> <!-- /modal-header -->
-      <div class="modal-body">
-      {% for param in widget.get_parameters() %}
-        <fieldset class="form-group ">
-          <strong>{{ param.label | capitalize }}</strong>
-          <input id="{{ param.name }}" class="form-control" type="text"
-              value="{{ param.validator.to_string(param.default) }}"
-              name="{{ param.name }}" />
-          {% if param.help %}
-          <small class="text-muted">{{ param.help }}</small>
-          {% endif %}
-        </fieldset>
-      {% else %}
-        <p>Nothing to configure</p>
-      {% endfor %}
-      {% if hub.config.visibility == "preview" %}
-        <fieldset class="form-group ">
-          <strong>Visibility</strong>
-          <select name="visibility" class="form-control">
-            <option value="public">Public</option>
-            <option value="restricted">Restricted</option>
-          </select>
-          <small class="text-muted">
-            Restricted widgets will only be visible by logged-in users.
-          </small>
-        </fieldset>
-      {% endif %}
-      </div> <!-- /modal-body -->
-      <div class="modal-footer">
-        <button type="button" class="btn btn-secondary" data-dismiss="modal">
-          Cancel
-        </button>
-        <button type="submit" class="btn btn-primary">
-            Add
-        </button>
-      </div> <!-- /modal-footer -->
-    </form>
-  </div>
-</div>
diff --git a/hubs/templates/widget_edit.html b/hubs/templates/widget_edit.html
deleted file mode 100644
index fc4f8ce..0000000
--- a/hubs/templates/widget_edit.html
+++ /dev/null
@@ -1,59 +0,0 @@
-<div class="modal-dialog widget-edit">
-  <div class="modal-content">
-    <form method="post" action="{{ url_for('widget_edit', hub=hub.name, idx=widget_instance.idx) }}">
-    <!-- Modal content-->
-      <div class="modal-header">
-        <button type="button" class="close" data-dismiss="modal">&times;</button>
-        <h4 class="modal-title">
-            Configure {{ widget_instance.plugin }}
-        </h4>
-      </div> <!-- /modal-header -->
-      <div class="modal-body">
-      {% for param in widget_instance.module.get_parameters() %}
-        <fieldset class="form-group ">
-          <strong>{{ param.label | capitalize }}</strong>
-          <input id="{{ param.name }}" class="form-control" type="text"
-              value="{{ param.validator.to_string(widget_instance.config.get(param.name, param.default)) }}"
-              name="{{ param.name }}" />
-          {% if param.help %}
-          <small class="text-muted">{{ param.help }}</small>
-          {% endif %}
-        </fieldset>
-      {% else %}
-        <p>Nothing to configure</p>
-      {% endfor %}
-      {% if hub.config.visibility == "preview" %}
-        <fieldset class="form-group ">
-          <strong>Visibility</strong>
-          <select name="visibility" class="form-control">
-            <option value="public"
-                {% if widget_instance.visibility == "public" %}selected{% endif %}
-                >Public</option>
-            <option value="restricted"
-                {% if widget_instance.visibility == "restricted" %}selected{% endif %}
-                >Restricted</option>
-          </select>
-          <small class="text-muted">
-            Restricted widgets will only be visible by logged-in users.
-          </small>
-        </fieldset>
-      {% endif %}
-      </div> <!-- /modal-body -->
-      <div class="modal-footer">
-        <button type="button" class="btn btn-secondary" data-dismiss="modal">
-          Close
-        </button>
-        <button type="submit" class="btn btn-primary">
-          Save
-        </button>
-      </div> <!-- /modal-footer -->
-    </form>
-    <form method="POST" action="{{ url_for(
-        'widget_edit_delete', hub=hub.name, idx=widget_instance.idx) }}"
-        class="modal-footer p-abs" >
-        <button type="submit" class="btn btn-danger" id="delete_widget">
-          Remove this widget
-        </button>
-    </form>
-  </div>
-</div>
diff --git a/hubs/widgets/base.py b/hubs/widgets/base.py
index b562662..698f34d 100644
--- a/hubs/widgets/base.py
+++ b/hubs/widgets/base.py
@@ -202,45 +202,6 @@ class Widget(object):
                     self.name, url_rule.lstrip("/"))
                 app.add_url_rule(rule, view_func=view_func)
 
-    def get_add_url(self, hub, position):
-        """Returns the URL to the configuration panel to add this widget.
-
-        By default, it returns the URL to the common widget configuration panel
-        that will simply display the widget's parameters in a form.  Widgets
-        override this method to implement a more complex configuration view.
-
-        If a more complex configuration view is implemented, the resulting HTML
-        code will be wrapped in a ``<div class="modal-dialog">`` tag.
-
-        Args:
-            hub (hubs.models.Hub): The hub instance in the database.
-            position (str): the position where the widget should be added. It
-                must be either ``left`` or ``right``, and must be in the
-                supported positions for this widget (see :py:attr:`position`).
-
-        Returns:
-            The URL to add this widget, as a string.
-        """
-        return "%s?position=%s" % (
-            flask.url_for('widget_add', hub=hub.name, widget=self.name),
-            position)
-
-    def get_edit_url(self, hub, widget_instance):
-        """Returns the URL to the configuration panel to edit this widget.
-
-        This method works like :py:meth:`get_add_url`.
-
-        Args:
-            hub (hubs.models.Hub): The hub instance in the database.
-            widget_instance (hubs.models.Widget): The widget instance in the
-                database that must be configured.
-
-        Returns:
-            The URL to edit this widget, as a string.
-        """
-        return flask.url_for(
-            'widget_edit', hub=hub.name, idx=widget_instance.idx)
-
     def get_cached_functions(self):
         """
         Returns:
diff --git a/hubs/widgets/halp/__init__.py b/hubs/widgets/halp/__init__.py
index 47b47fb..741b1e6 100644
--- a/hubs/widgets/halp/__init__.py
+++ b/hubs/widgets/halp/__init__.py
@@ -6,7 +6,7 @@ import flask
 
 from hubs.widgets.base import Widget
 from hubs.widgets import validators
-from .views import add_view, hubs_suggest_view
+from .views import hubs_suggest_view
 
 
 class Halp(Widget):
@@ -43,20 +43,8 @@ class Halp(Widget):
         env.filters['humanize'] = lambda d: arrow.get(d).humanize()
         return env
 
-    def get_add_url(self, hub, position):
-        return "%s?position=%s" % (
-            flask.url_for('halp_config_add', hub=hub.name), position)
-
-    def get_edit_url(self, hub, widget_instance):
-        return flask.url_for(
-            'halp_config', hub=hub.name, idx=widget_instance.idx)
-
     def register_routes(self, app):
         super(Halp, self).register_routes(app)
-        # Add the widget add view
-        rule = "/<hub>/w/%s/add" % self.name
-        endpoint = "%s_config_add" % self.name
-        app.add_url_rule(rule, endpoint=endpoint, view_func=add_view)
         # Add the hubs suggestion view
         rule = "/w/%s/hubs" % self.name
         endpoint = "%s_config_hubs" % self.name
diff --git a/hubs/widgets/halp/templates/halp_config.html b/hubs/widgets/halp/templates/halp_config.html
deleted file mode 100644
index 5587c3d..0000000
--- a/hubs/widgets/halp/templates/halp_config.html
+++ /dev/null
@@ -1,29 +0,0 @@
-<div id="widget-halp-config">
-  <div class="modal-dialog">
-    <div class="modal-content">
-      <div class="modal-body">
-        <img src="{{ url_for("static", filename="img/spinner.gif") }}"
-             class="mx-auto" alt="loading..." />
-      </div>
-    </div>
-  </div>
-</div>
-
-<script src="{{url_for('static', filename='js/build/Halp.js')}}"></script>
-<script>
-(function() {
-    const ConfigPanel = React.createElement(Halp.Config, {
-        mode: {{ mode|tojson }},
-        initialState: {{ initial|tojson }},
-        urls: {
-            post: {{ url|tojson }},
-            hubs: '{{ url_for("halp_config_hubs") }}',
-            {% if mode != "add" %}
-            remove: '{{ url_for("widget_edit_delete", hub=widget_instance.hub.name, idx=widget_instance.idx) }}',
-            {% endif %}
-            spinnerCircle: '{{ url_for("static", filename="img/spinner-circle.gif") }}'
-        }
-    });
-    ReactDOM.render(ConfigPanel, document.getElementById('widget-halp-config'));
-})();
-</script>
diff --git a/hubs/widgets/halp/views.py b/hubs/widgets/halp/views.py
index a00ad6e..dfd1e30 100644
--- a/hubs/widgets/halp/views.py
+++ b/hubs/widgets/halp/views.py
@@ -140,27 +140,6 @@ class RequestersView(WidgetView):
         return {"results": results}
 
 
-class ConfigView(WidgetView):
-    """
-    The custom configuration panel.
-    """
-
-    name = "config"
-    url_rules = ["config"]
-    template_name = "halp_config.html"
-    permission = "config"
-
-    def get_context(self, instance, *args, **kwargs):
-        post_url = flask.url_for(
-            'widget_edit', hub=instance.hub.name, idx=instance.idx)
-        initial = instance.config.copy()
-        return dict(
-            mode="edit",
-            url=post_url,
-            initial=initial,
-            )
-
-
 def hubs_suggest_view():
     """
     Returns suggestions for the "hubs" list in the configuration.
@@ -172,36 +151,3 @@ def hubs_suggest_view():
             "%s%%" % flask.request.args.get("q")))
     results = results.order_by(Hub.name)[:MAX_SUGGESTS]
     return flask.jsonify({"results": [h.name for h in results]})
-
-
-@require_hub_access("config", url_param="hub")
-def add_view(hub):
-    """The custom configuration panel when adding the widget.
-
-    This is not a subclass of WidgetView because it is not specific
-    to a widget instance, therefore it does not have the same URL
-    parameters.
-    """
-    hub = get_hub(hub)
-    widget = registry["halp"]
-    tpl_env = widget.get_template_environment()
-    template = tpl_env.get_template("halp_config.html")
-    post_url = "%s?position=%s" % (
-        flask.url_for('widget_add', hub=hub.name, widget="halp"),
-        flask.request.args.get("position"))
-    initial = dict(
-        (param.name, param.default)
-        for param in widget.get_parameters()
-        )
-    initial["hubs"] = [hub.name]
-    context = dict(mode="add", url=post_url, initial=initial)
-    # Mimic flask.templating._render(). Too bad it's not a public API.
-    if before_render_template is not None:
-        before_render_template.send(
-            flask._app_ctx_stack.top.app,
-            template=template, context=context)
-    output = template.render(**context)
-    template_rendered.send(
-        flask._app_ctx_stack.top.app,
-        template=template, context=context)
-    return output

From 52964f9665949c2219418a580b2b39d57e83fcc2 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Sep 28 2017 17:35:09 +0000
Subject: [PATCH 10/20] The widget panel wrapper is handled in React


---

diff --git a/hubs/widgets/__init__.py b/hubs/widgets/__init__.py
index 76f17b0..be454bd 100644
--- a/hubs/widgets/__init__.py
+++ b/hubs/widgets/__init__.py
@@ -40,15 +40,10 @@ use of any Jinja2 feature, including inheritance, macros, etc.
 
 .. _Jinja2: http://jinja.pocoo.org/docs/
 
-If you want to add filters to youre widget's Jinja2 environment, you can do so
+If you want to add filters to your widget's Jinja2 environment, you can do so
 by overriding the widget's
 :py:meth:`~hubs.widgets.base.Widget.get_template_environment` method.
 
-Hubs provides the ``panel.html`` master template for widgets that want their
-output to be wrapped in the common panel chrome, just use the ``extends`` tag
-to take advantage of it.  You can also set a ``title`` variable in your root
-view context, and it will be used for the panel title.
-
 Refer to the API documentation to learn more on how to write a widget view.
 
 A widget can also have any number of cached functions.  A cached function is a
diff --git a/hubs/widgets/about/__init__.py b/hubs/widgets/about/__init__.py
index 0c01124..0da5c7b 100644
--- a/hubs/widgets/about/__init__.py
+++ b/hubs/widgets/about/__init__.py
@@ -23,6 +23,4 @@ class BaseView(RootWidgetView):
     def get_context(self, instance, *args, **kwargs):
         return dict(
             text=instance.config["text"],
-            title="<span class='glyphicon glyphicon-info-sign' "
-                  "aria-hidden='true'></span> About",
             )
diff --git a/hubs/widgets/about/templates/root.html b/hubs/widgets/about/templates/root.html
index eafc55a..9089da2 100644
--- a/hubs/widgets/about/templates/root.html
+++ b/hubs/widgets/about/templates/root.html
@@ -1,5 +1 @@
-{% extends "panel.html" %}
-
-{% block content %}
-<p>{{text}}</p>
-{% endblock %}
+<p class="mb-0">{{text}}</p>
diff --git a/hubs/widgets/badges/__init__.py b/hubs/widgets/badges/__init__.py
index f3e8f78..505d2b3 100644
--- a/hubs/widgets/badges/__init__.py
+++ b/hubs/widgets/badges/__init__.py
@@ -25,7 +25,7 @@ class Badges(Widget):
 class BaseView(RootWidgetView):
 
     def get_context(self, instance, *args, **kwargs):
-        context = {"title": self.widget.label}
+        context = {}
         get_badges = GetBadges(instance)
         context.update(get_badges())
         return context
diff --git a/hubs/widgets/badges/templates/root.html b/hubs/widgets/badges/templates/root.html
index c7201d2..8f5056f 100644
--- a/hubs/widgets/badges/templates/root.html
+++ b/hubs/widgets/badges/templates/root.html
@@ -1,6 +1,3 @@
-{% extends "panel.html" %}
-
-{% block content %}
 <div class="flex-container">
 {% for badge in assertions %}
   <a width="60px" href="https://badges.fedoraproject.org/badge/{{badge['id']}}">
@@ -8,4 +5,3 @@
   </a>
 {% endfor %}
 </div>
-{% endblock %}
diff --git a/hubs/widgets/base.py b/hubs/widgets/base.py
index 698f34d..2dad1a1 100644
--- a/hubs/widgets/base.py
+++ b/hubs/widgets/base.py
@@ -60,6 +60,7 @@ class Widget(object):
             URL-compatible characters.
         label (str): A humanized name for the widget, which will be shown in
             the UI.
+        display_css (str): A CSS class to use when displaying the widget.
         position (str): The position of the widget in the rendered page. It
             should be one of the following values: ``left``, ``right``, or
             ``both``.
@@ -71,14 +72,20 @@ class Widget(object):
         cached_functions_module (list): The Python path to the module where the
             cached functions must be looked for.  By default, they are looked
             for in the same module.
+        hidden_if_empty (bool): Set to True if the widget box should be
+            entirely hidden from the UI when no data is produced. As a
+            consequence, there will also be no progress spinner when the widget
+            is loading.
     """
 
     name = None
     label = None
+    display_css = None
     position = None
     parameters = []
     views_module = None
     cached_functions_module = None
+    hidden_if_empty = False
 
     def __init__(self):
         if self.name is None:
diff --git a/hubs/widgets/bugzilla/__init__.py b/hubs/widgets/bugzilla/__init__.py
index 363f7b2..a0643cc 100644
--- a/hubs/widgets/bugzilla/__init__.py
+++ b/hubs/widgets/bugzilla/__init__.py
@@ -39,7 +39,6 @@ class BaseView(RootWidgetView):
     def get_context(self, instance, *args, **kwargs):
         get_issues = GetIssues(instance)
         return dict(
-            title=self.widget.label,
             username=instance.config["username"],
             issues=get_issues()
             )
diff --git a/hubs/widgets/bugzilla/templates/root.html b/hubs/widgets/bugzilla/templates/root.html
index 2ee01e7..d88d6bd 100644
--- a/hubs/widgets/bugzilla/templates/root.html
+++ b/hubs/widgets/bugzilla/templates/root.html
@@ -1,6 +1,3 @@
-{% extends "panel.html" %}
-
-{% block content %}
 <ul class="list-unstyled mb-0">
   {% for item in issues %}
     <li class="media py-2">
@@ -18,4 +15,3 @@
     </li>
   {% endfor %}
 </ul>
-{% endblock %}
diff --git a/hubs/widgets/contact/__init__.py b/hubs/widgets/contact/__init__.py
index 288fc92..6992015 100644
--- a/hubs/widgets/contact/__init__.py
+++ b/hubs/widgets/contact/__init__.py
@@ -14,6 +14,7 @@ class Contact(Widget):
 
     name = "contact"
     position = "both"
+    display_title = None
 
 
 class BaseView(RootWidgetView):
diff --git a/hubs/widgets/contact/templates/root.html b/hubs/widgets/contact/templates/root.html
index d4844e9..7c3cccf 100644
--- a/hubs/widgets/contact/templates/root.html
+++ b/hubs/widgets/contact/templates/root.html
@@ -1,6 +1,3 @@
-{% extends "panel.html" %}
-
-{% block content %}
 <div class="contactinfo-container">
   {% if usergroup %}
     <ul style="list-style: none; padding-left: 5px">
@@ -71,4 +68,3 @@
     </ul>
   {% endif %}
 </div>
-{% endblock %}
diff --git a/hubs/widgets/dummy/__init__.py b/hubs/widgets/dummy/__init__.py
index d6733b7..c5e4f1d 100644
--- a/hubs/widgets/dummy/__init__.py
+++ b/hubs/widgets/dummy/__init__.py
@@ -22,6 +22,5 @@ class BaseView(RootWidgetView):
 
     def get_context(self, instance, *args, **kwargs):
         return dict(
-            title="This is a dummy widget",
             text=instance.config["text"],
             )
diff --git a/hubs/widgets/dummy/templates/root.html b/hubs/widgets/dummy/templates/root.html
index f2b2486..8d15644 100644
--- a/hubs/widgets/dummy/templates/root.html
+++ b/hubs/widgets/dummy/templates/root.html
@@ -1,5 +1 @@
-{% extends "panel.html" %}
-
-{% block content %}
 {{text}}
-{% endblock %}
diff --git a/hubs/widgets/fedmsgstats/templates/root.html b/hubs/widgets/fedmsgstats/templates/root.html
index 6c6efe1..73664d0 100644
--- a/hubs/widgets/fedmsgstats/templates/root.html
+++ b/hubs/widgets/fedmsgstats/templates/root.html
@@ -1,6 +1,3 @@
-{% extends "panel.html" %}
-
-{% block content %}
 <div class="stats-container row">
   <div class="col-sm-7 col-md-12 col-lg-7">
   <table class="stats-table">
@@ -24,4 +21,3 @@
   {% endif %}
   </div>
 </div>
-{% endblock %}
diff --git a/hubs/widgets/github_pr/__init__.py b/hubs/widgets/github_pr/__init__.py
index f52c149..a8b350a 100644
--- a/hubs/widgets/github_pr/__init__.py
+++ b/hubs/widgets/github_pr/__init__.py
@@ -43,7 +43,6 @@ class BaseView(RootWidgetView):
         context = dict(
             organization=org,
             display_number=instance.config["display_number"],
-            title=self.widget.label,
         )
         context.update(get_prs())
         return context
diff --git a/hubs/widgets/github_pr/templates/root.html b/hubs/widgets/github_pr/templates/root.html
index ff31d64..2d46c56 100644
--- a/hubs/widgets/github_pr/templates/root.html
+++ b/hubs/widgets/github_pr/templates/root.html
@@ -1,6 +1,3 @@
-{% extends "panel.html" %}
-
-{% block content %}
 <a class="btn btn-success" target="_blank"
     href="https://github.com/{{ organization }}" >
   Organization Github Page
@@ -47,4 +44,3 @@
   </li>
 {% endif %}
 </ul>
-{% endblock %}
diff --git a/hubs/widgets/githubissues/__init__.py b/hubs/widgets/githubissues/__init__.py
index 0575545..fa0c996 100644
--- a/hubs/widgets/githubissues/__init__.py
+++ b/hubs/widgets/githubissues/__init__.py
@@ -43,7 +43,6 @@ class BaseView(RootWidgetView):
             org=instance.config["org"],
             repo=instance.config["repo"],
             display_number=instance.config["display_number"],
-            title=self.widget.label,
             all_issues=get_issues(),
         )
 
diff --git a/hubs/widgets/githubissues/templates/root.html b/hubs/widgets/githubissues/templates/root.html
index 8f0cb1d..4200aca 100644
--- a/hubs/widgets/githubissues/templates/root.html
+++ b/hubs/widgets/githubissues/templates/root.html
@@ -1,9 +1,5 @@
-{% extends "panel.html" %}
-
-{% block content %}
-
 <ul class="list-unstyled">
-    
+
 {% for i in range(display_number) %}
 <li class="media">
   <div class="align-self-center mr-3">
@@ -33,9 +29,7 @@
          </a>
       </div>
     </div>
-    
-             
-    </div>
+  </div>
 </li>
 {% endfor %}
 
@@ -44,4 +38,3 @@
 <div class="row">
   <a href="https://github.com/{{ org }}/{{ repo }}/issues" target="_blank"><center>All Issues</center></a>
 </div>
-{% endblock %}
diff --git a/hubs/widgets/library/__init__.py b/hubs/widgets/library/__init__.py
index 3eddd2d..e8315d3 100644
--- a/hubs/widgets/library/__init__.py
+++ b/hubs/widgets/library/__init__.py
@@ -29,7 +29,4 @@ class BaseView(RootWidgetView):
             for u in instance.config.get('urls', '').split(',')
             if u.strip()
         ]
-        return dict(
-            title=self.widget.label,
-            urls=urls,
-            )
+        return dict(urls=urls)
diff --git a/hubs/widgets/library/templates/root.html b/hubs/widgets/library/templates/root.html
index b32cfcb..4cbff48 100644
--- a/hubs/widgets/library/templates/root.html
+++ b/hubs/widgets/library/templates/root.html
@@ -1,6 +1,3 @@
-{% extends "panel.html" %}
-
-{% block content %}
 <div class="rules-container">
   <div class="row">
     <ul>
@@ -12,4 +9,3 @@
     </ul>
   </div>
 </div>
-{% endblock %}
diff --git a/hubs/widgets/linechart/__init__.py b/hubs/widgets/linechart/__init__.py
index 39b5be8..9fdccb2 100644
--- a/hubs/widgets/linechart/__init__.py
+++ b/hubs/widgets/linechart/__init__.py
@@ -38,7 +38,4 @@ class BaseView(RootWidgetView):
                "&user={username}&split_on=categories")
         url = url + "&" + categories
         url = url.format(username=username)
-        return dict(
-            title=self.widget.label,
-            url=url,
-            )
+        return dict(url=url)
diff --git a/hubs/widgets/linechart/templates/root.html b/hubs/widgets/linechart/templates/root.html
index 573d452..5b4e756 100644
--- a/hubs/widgets/linechart/templates/root.html
+++ b/hubs/widgets/linechart/templates/root.html
@@ -1,7 +1,3 @@
-{% extends "panel.html" %}
-
-{% block content %}
 <div class="flex-container">
     <img src="{{url}}" />
 </div>
-{% endblock %}
diff --git a/hubs/widgets/meetings/__init__.py b/hubs/widgets/meetings/__init__.py
index 0633d40..9d8fc2e 100644
--- a/hubs/widgets/meetings/__init__.py
+++ b/hubs/widgets/meetings/__init__.py
@@ -49,7 +49,6 @@ class BaseView(RootWidgetView):
             if meeting['start_dt'] > now
             }
         return dict(
-            title=self.widget.label,
             calendar=instance.config["calendar"],
             meetings=meetings,
             )
diff --git a/hubs/widgets/meetings/templates/root.html b/hubs/widgets/meetings/templates/root.html
index 3b49aba..7028ac2 100644
--- a/hubs/widgets/meetings/templates/root.html
+++ b/hubs/widgets/meetings/templates/root.html
@@ -1,6 +1,3 @@
-{% extends "panel.html" %}
-
-{% block content %}
 {% for title, next in meetings.items() %}
 <div class="meeting">
   <div class="row no-gutters">
@@ -48,16 +45,9 @@
     No coming meetings in {{ calendar }}.
   </div>
 </div>
+<p class="text-center mt-3">
+  <button class="btn btn-secondary">
+    <strong>Request A New Meeting</strong>
+  </button>
+</p>
 {% endfor %}
-{% endblock %}
-
-
-{% block footer %}
-<div class="panel-footer">
-  <div class="text-center">
-    <button class="btn btn-secondary">
-      <strong>Request A New Meeting</strong>
-    </button>
-  </div>
-</div>
-{% endblock %}
diff --git a/hubs/widgets/memberships/__init__.py b/hubs/widgets/memberships/__init__.py
index ff57c74..78433b8 100644
--- a/hubs/widgets/memberships/__init__.py
+++ b/hubs/widgets/memberships/__init__.py
@@ -36,7 +36,6 @@ class BaseView(RootWidgetView):
         oldest_members = sorted(
             members, key=lambda m: m.get('created_on'))[:ELLIPSIS_LIMIT]
         return dict(
-            title=self.widget.label,
             memberships=list(members),
             oldest_members=list(oldest_members),
             )
diff --git a/hubs/widgets/memberships/templates/root.html b/hubs/widgets/memberships/templates/root.html
index 621ea9f..d195b0c 100644
--- a/hubs/widgets/memberships/templates/root.html
+++ b/hubs/widgets/memberships/templates/root.html
@@ -1,6 +1,3 @@
-{% extends "panel.html" %}
-
-{% block content %}
 <div class="card-block">
   {% if memberships|length > oldest_members|length %}
   {% for member in oldest_members %}
@@ -77,4 +74,3 @@
    padding: 10px;
  }
 </style>
-{% endblock %}
diff --git a/hubs/widgets/pagure_pr/__init__.py b/hubs/widgets/pagure_pr/__init__.py
index 9346e54..4dc5562 100644
--- a/hubs/widgets/pagure_pr/__init__.py
+++ b/hubs/widgets/pagure_pr/__init__.py
@@ -30,7 +30,6 @@ class BaseView(RootWidgetView):
     def get_context(self, instance, *args, **kwargs):
         get_prs = GetPRs(instance)
         context = dict(
-            title=self.widget.label,
             repo=instance.config["repo"],
             )
         context.update(get_prs())
diff --git a/hubs/widgets/pagure_pr/templates/root.html b/hubs/widgets/pagure_pr/templates/root.html
index 2207ce3..bf8ae43 100644
--- a/hubs/widgets/pagure_pr/templates/root.html
+++ b/hubs/widgets/pagure_pr/templates/root.html
@@ -1,6 +1,3 @@
-{% extends "panel.html" %}
-
-{% block content %}
 <ul class="list-unstyled">
   {% for pr in all_pr[:10] %}
     <li class="media py-2">
@@ -51,4 +48,3 @@
     href="https://pagure.io/{{ repo }}/pull-requests">
   All Pull-Requests
 </a>
-{% endblock %}
diff --git a/hubs/widgets/pagureissues/__init__.py b/hubs/widgets/pagureissues/__init__.py
index 3ba90d9..4cc033f 100644
--- a/hubs/widgets/pagureissues/__init__.py
+++ b/hubs/widgets/pagureissues/__init__.py
@@ -30,7 +30,6 @@ class BaseView(RootWidgetView):
     def get_context(self, instance, *args, **kwargs):
         get_issues = GetIssues(instance)
         context = dict(
-            title=self.widget.label,
             repo=instance.config["repo"],
             )
         context.update(get_issues())
diff --git a/hubs/widgets/pagureissues/templates/root.html b/hubs/widgets/pagureissues/templates/root.html
index 5e4fbd5..53823b7 100644
--- a/hubs/widgets/pagureissues/templates/root.html
+++ b/hubs/widgets/pagureissues/templates/root.html
@@ -1,6 +1,3 @@
-{% extends "panel.html" %}
-
-{% block content %}
 <a class="btn btn-success" target="_blank"
     href="https://pagure.io/{{ repo }}/issues">
   All Issues
@@ -50,4 +47,3 @@
   </li>
   {% endif %}
 </ul>
-{% endblock %}
diff --git a/hubs/widgets/rules/templates/root.html b/hubs/widgets/rules/templates/root.html
index 0803f95..e3abc68 100644
--- a/hubs/widgets/rules/templates/root.html
+++ b/hubs/widgets/rules/templates/root.html
@@ -1,6 +1,3 @@
-{% extends "panel.html" %}
-
-{% block content %}
 <div class="rules-container">
   {% if link %}
   <h6>community rules</h6>
@@ -88,4 +85,3 @@
    border-bottom: 0;
  }
 </style>
-{% endblock %}
diff --git a/hubs/widgets/stats/templates/root.html b/hubs/widgets/stats/templates/root.html
index 5dc307e..22aa78c 100644
--- a/hubs/widgets/stats/templates/root.html
+++ b/hubs/widgets/stats/templates/root.html
@@ -1,6 +1,3 @@
-{% extends "panel.html" %}
-
-{% block content %}
 <div class="stats-container row">
 <div class="col-md-7">
   <table class="stats-table">
@@ -75,4 +72,3 @@
   </ul>
 </div>
 </div>
-{% endblock %}
diff --git a/hubs/widgets/sticky/__init__.py b/hubs/widgets/sticky/__init__.py
index f452d24..13c6af3 100644
--- a/hubs/widgets/sticky/__init__.py
+++ b/hubs/widgets/sticky/__init__.py
@@ -10,6 +10,7 @@ class Sticky(Widget):
     name = "sticky"
     label = "Sticky Note"
     position = "both"
+    display_css = "card-info"
     parameters = [
         dict(
             name="text",
@@ -26,6 +27,4 @@ class BaseView(RootWidgetView):
         # TODO -- render with markdown
         return dict(
             text=instance.config["text"],
-            title=self.widget.label,
-            panel_css_class="card-info",
             )
diff --git a/hubs/widgets/sticky/templates/root.html b/hubs/widgets/sticky/templates/root.html
index f2b2486..8d15644 100644
--- a/hubs/widgets/sticky/templates/root.html
+++ b/hubs/widgets/sticky/templates/root.html
@@ -1,5 +1 @@
-{% extends "panel.html" %}
-
-{% block content %}
 {{text}}
-{% endblock %}
diff --git a/hubs/widgets/subscriptions/__init__.py b/hubs/widgets/subscriptions/__init__.py
index c3b9cb8..01a1f57 100644
--- a/hubs/widgets/subscriptions/__init__.py
+++ b/hubs/widgets/subscriptions/__init__.py
@@ -19,6 +19,8 @@ class Subscriptions(Widget):
 
     name = "subscriptions"
     position = "right"
+    display_title = "Hubs"
+    hidden_if_empty = True
     parameters = [
         dict(
             name="username",
@@ -32,7 +34,7 @@ class Subscriptions(Widget):
 class BaseView(RootWidgetView):
 
     def get_context(self, instance, *args, **kwargs):
-        context = dict(title="Hubs")
+        context = {}
         get_subs = GetSubs(instance)
         context.update(get_subs())
         return context
diff --git a/hubs/widgets/subscriptions/templates/root.html b/hubs/widgets/subscriptions/templates/root.html
index 83033dd..248d86f 100644
--- a/hubs/widgets/subscriptions/templates/root.html
+++ b/hubs/widgets/subscriptions/templates/root.html
@@ -1,7 +1,5 @@
-{% if associations or edit_mode %}
-{% extends "panel.html" %}
+{% if associations %}
 
-{% block content %}
     {% if memberships %}
         <p><strong>Belongs to: </strong> {{memberships_text}}</p>
     {% endif %}
@@ -13,5 +11,5 @@
         {{subscription}}</a>
         {% endfor %}</p>
     {% endif %}
-{% endblock %}
+
 {% endif %}
diff --git a/hubs/widgets/templates/panel.html b/hubs/widgets/templates/panel.html
deleted file mode 100644
index 9374f19..0000000
--- a/hubs/widgets/templates/panel.html
+++ /dev/null
@@ -1,26 +0,0 @@
-<div class="card {{ panel_css_class }} widget-{{ widget.name }}"
-     {% if disable_autoreload %}data-disable-autoreload="true"{% endif %}
-    >
-  <div class="widget-buttons">
-    <!-- the AGPLv3 wrapper puts the source url in all responses -->
-    <a href="{{ url_for('widget_source', name=widget.name) }}"><span><i class="fa fa-eye" aria-hidden="true"></i></span></a>
-    <a href="{{ url_for('%s_root' % widget.name, hub=widget_instance.hub.name, idx=widget_instance.idx) }}">
-        <span><i class="fa fa-external-link" aria-hidden="true"></i></span>
-    </a>
-    {% if edit_mode and widget_instance.hub.allows(g.user, "config") %}
-    <a data-target="#edit_modal" data-toggle="modal" type="button"
-        class="edit_widget" data-url="{{ widget_instance.edit_url }}">
-      <span><i class="fa fa-cog" aria-hidden="true"></i></span>
-    </a>
-    {% endif %}
-  </div>
-  {% if title %}
-  <div class="card-header">
-    {{title}}
-  </div> <!-- end card-header -->
-  {% endif %}
-  <div class="card-block">
-    {% block content %}{% endblock %}
-  </div> <!-- end card-block -->
-  {% block footer %}{% endblock %}
-</div> <!-- end card -->
diff --git a/hubs/widgets/workflow/templates/updates2stable.html b/hubs/widgets/workflow/templates/updates2stable.html
index e44e040..5ba04b0 100644
--- a/hubs/widgets/workflow/templates/updates2stable.html
+++ b/hubs/widgets/workflow/templates/updates2stable.html
@@ -1,27 +1,23 @@
-{% if updates or edit_mode %}
-{% extends "panel.html" %}
-
-{% block content %}
-{% if session['nickname'] == username %}
-<a class="btn btn-success" target="_blank" href="https://admin.fedoraproject.org/updates/mine">Manage updates</a>
-<hr/>
-{% endif %}
-<ul class="list-unstyled mb-0">
-{% for update in updates %}
-    <li class="media">
-        <div class="align-self-center mr-3">
-            <a href="{{update['link']}}" target="_blank">
-                <img class="media-object square-32" src="{{update['icon']}}"/>
-            </a>
-        </div>
-        <div class="media-body">
-            <h5 class="mt-0">
-                {{update['title']}} (karma {{update['karma']}})
-            </h5>
-            can be pushed to stable
-        </div>
-    </li>
-{% endfor %}
-</ul>
-{% endblock %}
+{% if updates %}
+    {% if session['nickname'] == username %}
+    <a class="btn btn-success" target="_blank" href="https://admin.fedoraproject.org/updates/mine">Manage updates</a>
+    <hr/>
+    {% endif %}
+    <ul class="list-unstyled mb-0">
+    {% for update in updates %}
+        <li class="media">
+            <div class="align-self-center mr-3">
+                <a href="{{update['link']}}" target="_blank">
+                    <img class="media-object square-32" src="{{update['icon']}}"/>
+                </a>
+            </div>
+            <div class="media-body">
+                <h5 class="mt-0">
+                    {{update['title']}} (karma {{update['karma']}})
+                </h5>
+                can be pushed to stable
+            </div>
+        </li>
+    {% endfor %}
+    </ul>
 {% endif %}
diff --git a/hubs/widgets/workflow/updates2stable.py b/hubs/widgets/workflow/updates2stable.py
index 25cc62d..9845cc4 100644
--- a/hubs/widgets/workflow/updates2stable.py
+++ b/hubs/widgets/workflow/updates2stable.py
@@ -18,6 +18,7 @@ class Updates2Stable(Widget):
     name = "workflow.updates2stable"
     label = 'Updates Ready for Stable'
     position = "right"
+    hidden_if_empty = True
     parameters = [
         dict(
             name="username",
@@ -35,7 +36,6 @@ class BaseView(RootWidgetView):
     def get_context(self, instance, *args, **kwargs):
         get_pending = GetPending(instance)
         context = dict(
-            title=self.widget.label,
             username=instance.config["username"],
             )
         context.update(get_pending())

From 4468ec9f0698ff6990e4d6922a6f5a0f0f73063b Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Sep 28 2017 17:35:09 +0000
Subject: [PATCH 11/20] Hubs and widgets expose 'props' to the JS UI


---

diff --git a/hubs/models.py b/hubs/models.py
index 55530f9..3cc5850 100644
--- a/hubs/models.py
+++ b/hubs/models.py
@@ -299,6 +299,26 @@ class Hub(ObjectAuthzMixin, BASE):
             action = "{}.view".format(self.config.visibility)
         return "hub.{}".format(action)
 
+    def get_props(self):
+        """Get the hub properties for the Javascrip UI"""
+        roles = ["owner", "member"]
+        result = {
+            "name": self.name,
+            "config": self.config.__json__(),
+            "users": {role: [] for role in roles},
+            "mtime": self.last_refreshed,
+            "user_hub": self.user_hub,
+        }
+        for assoc in sorted(self.associations, key=lambda a: a.user.username):
+            if assoc.role not in roles:
+                continue
+            user = {
+                "username": assoc.user.username,
+                "fullname": assoc.user.fullname,
+            }
+            result["users"][assoc.role].append(user)
+        return result
+
     def __json__(self):
         return {
             'name': self.name,
@@ -458,6 +478,8 @@ class Widget(ObjectAuthzMixin, BASE):
     def module(self):
         return hubs.widgets.registry[self.plugin]
 
+    def get_props(self, with_secret_config=False):
+        return self.module.get_props(self, with_secret_config)
 
     @property
     def enabled(self):
diff --git a/hubs/tests/test_widget_base.py b/hubs/tests/test_widget_base.py
index 832c2f8..cb834d1 100644
--- a/hubs/tests/test_widget_base.py
+++ b/hubs/tests/test_widget_base.py
@@ -4,7 +4,7 @@ import six
 from mock import Mock
 
 from hubs.app import app
-from hubs.tests import APPTest
+from hubs.tests import APPTest, widget_instance
 from hubs.widgets.base import Widget
 from hubs.widgets.caching import CachedFunction
 from hubs.widgets.view import WidgetView
@@ -140,3 +140,28 @@ class WidgetTest(APPTest):
         rules = list(app.url_map.iter_rules(endpoint="testing_test2"))
         self.assertEqual(len(rules), 1)
         self.assertEqual(rules[0].rule, "/<hub>/w/testing/<int:idx>/test-2")
+
+    def test_get_props(self):
+        widget = widget_instance('ralph', "about")
+        with app.test_request_context('/'):
+            props = widget.get_props()
+        self.assertDictEqual(props, {
+            'config': {'text': 'Testing.'},
+            'contentUrl': '/ralph/w/about/{}/'.format(widget.idx),
+            'cssClass': None,
+            'hiddenIfEmpty': False,
+            'idx': widget.idx,
+            'index': 500,
+            'isReact': False,
+            'label': 'About',
+            'name': 'about',
+            'params': [
+                {'default': 'I am a Fedora user, and this is my about',
+                 'help': 'Text about a user.',
+                 'label': 'Text',
+                 'name': 'text'},
+            ],
+            'position': 'right',
+            'selfUrl': '/api/hubs/ralph/widgets/{}/'.format(widget.idx),
+            'title': 'About',
+        })
diff --git a/hubs/tests/widgets/test_halp.py b/hubs/tests/widgets/test_halp.py
index 5a489d3..f160993 100644
--- a/hubs/tests/widgets/test_halp.py
+++ b/hubs/tests/widgets/test_halp.py
@@ -117,6 +117,43 @@ class HalpViewsTestCase(WidgetTest):
     def test_view_authz(self):
         self._test_view_authz()
 
+    def test_props(self):
+        with app.test_request_context('/'):
+            props = self.widget.get_props()
+        self.assertDictEqual(props, {
+            'component': 'halp',
+            'config': {'hubs': ['fedora-devel'], 'per_page': 3},
+            'cssClass': None,
+            'hiddenIfEmpty': False,
+            'idx': self.widget.idx,
+            'index': 9,
+            'isReact': True,
+            'label': 'Help Requests',
+            'name': 'halp',
+            'params': [
+                {'default': [],
+                 'help': 'A comma-separated list of hubs to monitor.',
+                 'label': 'Hubs',
+                 'name': 'hubs',
+                 },
+                {'default': 4,
+                 'help': 'The number of requests per page to display.',
+                 'label': 'Requests per page',
+                 'name': 'per_page',
+                 },
+            ],
+            'position': 'right',
+            'selfUrl': '/api/hubs/ralph/widgets/{}/'.format(self.widget.idx),
+            'title': 'Help Requests',
+            'urls': {
+                'allHubs': '/w/halp/hubs',
+                'data': '/ralph/w/halp/{}/data'.format(self.widget.idx),
+                'requesters': '/ralph/w/halp/{}/requesters'.format(
+                    self.widget.idx),
+                'search': '/ralph/w/halp/{}/search'.format(self.widget.idx),
+            }
+        })
+
     def test_config_hubs_suggest(self):
         response = self.app.get('/w/halp/hubs')
         self.assertEqual(response.status_code, 200)
diff --git a/hubs/widgets/base.py b/hubs/widgets/base.py
index 2dad1a1..5523385 100644
--- a/hubs/widgets/base.py
+++ b/hubs/widgets/base.py
@@ -36,9 +36,12 @@ class WidgetParameter(object):
             the parameter value to a JSON-serializable value, raising an
             exception if it is invalid.
         help (str): A help text that will be shown in the UI.
+        secret (bool): If True, this parameter will not be exposed to users
+            viewing the widget, it will only be available in the widget
+            configuration.
     """
 
-    _attrs = ('name', 'label', 'default', 'validator', 'help')
+    _attrs = ('name', 'label', 'default', 'validator', 'help', 'secret')
 
     def __init__(self, **kwargs):
         for name in self._attrs:
@@ -72,6 +75,7 @@ class Widget(object):
         cached_functions_module (list): The Python path to the module where the
             cached functions must be looked for.  By default, they are looked
             for in the same module.
+        is_react (bool): Set to True if the widget is React-based.
         hidden_if_empty (bool): Set to True if the widget box should be
             entirely hidden from the UI when no data is produced. As a
             consequence, there will also be no progress spinner when the widget
@@ -85,6 +89,7 @@ class Widget(object):
     parameters = []
     views_module = None
     cached_functions_module = None
+    is_react = False
     hidden_if_empty = False
 
     def __init__(self):
@@ -220,3 +225,62 @@ class Widget(object):
                 CachedFunction, self.cached_functions_module):
             result[fn_class.__name__] = fn_class
         return result
+
+    @property
+    def display_title(self):
+        """The title of the widget box in the UI."""
+        return self.label
+
+    def get_props(self, instance, with_secret_config=False):
+        """Generate the widget's properties for the Javascript UI.
+
+        Args:
+            instance (:py:class:`~hubs.models.Widget` or None): the widget
+                instance to generate the properties for. If None, only generate
+                the generic properties for this widget class.
+            with_secret_config (bool): include widget parameter values that are
+                marked as secret.
+
+        Returns:
+            dict: A dictionary that will be serialized to JSON
+        """
+        props = dict(
+            name=self.name,
+            label=self.label,
+            title=self.display_title,
+            position=self.position,
+            hiddenIfEmpty=self.hidden_if_empty,
+            cssClass=self.display_css,
+            isReact=self.is_react,
+            params=[],
+        )
+        for param in self.get_parameters():
+            param_data = dict(
+                name=param.name,
+                label=param.label,
+                default=param.default,
+                help=param.help,
+                )
+            props["params"].append(param_data)
+        if instance is not None:
+            props.update({
+                "idx": instance.idx,
+                "position": "left" if instance.left else "right",
+                "index": instance.index,
+                "selfUrl": flask.url_for(
+                    "api_hub_widget", hub=instance.hub.name, idx=instance.idx),
+                "config": {},
+            })
+            for param in self.get_parameters():
+                if param.secret and not with_secret_config:
+                    continue
+                value = param.validator(instance.config[param.name])
+                props["config"][param.name] = value
+            if self.is_react:
+                props["component"] = self.name
+            else:
+                props["contentUrl"] = flask.url_for(
+                    "%s_root" % self.name,
+                    hub=instance.hub.name,
+                    idx=instance.idx)
+        return props
diff --git a/hubs/widgets/feed/__init__.py b/hubs/widgets/feed/__init__.py
index f37ec2e..a9a265d 100644
--- a/hubs/widgets/feed/__init__.py
+++ b/hubs/widgets/feed/__init__.py
@@ -3,6 +3,8 @@ from __future__ import unicode_literals, absolute_import
 
 import logging
 
+import flask
+
 from hubs.feed import format_msgs
 from hubs.widgets import validators
 from hubs.widgets.base import Widget
@@ -28,6 +30,17 @@ class Feed(Widget):
             "help": "Max number of feed messages to display.",
         }]
     cached_functions_module = ".functions"
+    is_react = True
+
+    def get_props(self, instance, *args, **kwargs):
+        props = super(Feed, self).get_props(instance, *args, **kwargs)
+        if instance is not None:
+            hub_name = instance.hub.name
+            props.update(dict(
+                url=flask.url_for(
+                    "feed_existing", hub=hub_name, idx=instance.idx),
+                ))
+        return props
 
 
 class BaseView(RootWidgetView):
diff --git a/hubs/widgets/halp/__init__.py b/hubs/widgets/halp/__init__.py
index 741b1e6..8fc82d4 100644
--- a/hubs/widgets/halp/__init__.py
+++ b/hubs/widgets/halp/__init__.py
@@ -37,6 +37,7 @@ class Halp(Widget):
         )]
     views_module = ".views"
     cached_functions_module = ".functions"
+    is_react = True
 
     def get_template_environment(self):
         env = super(Halp, self).get_template_environment()
@@ -49,3 +50,20 @@ class Halp(Widget):
         rule = "/w/%s/hubs" % self.name
         endpoint = "%s_config_hubs" % self.name
         app.add_url_rule(rule, endpoint=endpoint, view_func=hubs_suggest_view)
+
+    def get_props(self, instance, *args, **kwargs):
+        props = super(Halp, self).get_props(instance, *args, **kwargs)
+        props["urls"] = dict(
+            allHubs=flask.url_for("halp_config_hubs"),
+        )
+        if instance is not None:
+            hub_name = instance.hub.name
+            props["urls"].update(dict(
+                data=flask.url_for(
+                    "halp_data", hub=hub_name, idx=instance.idx),
+                search=flask.url_for(
+                    "halp_search", hub=hub_name, idx=instance.idx),
+                requesters=flask.url_for(
+                    "halp_requesters", hub=hub_name, idx=instance.idx),
+                ))
+        return props

From ed878ec261a738a7a4fbc86fc89f7c1eb03b4df2 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Sep 28 2017 17:35:09 +0000
Subject: [PATCH 12/20] Prepare the root view to use React only for the UI


---

diff --git a/hubs/static/css/style.css b/hubs/static/css/style.css
index 8dd75cd..d19cc3e 100644
--- a/hubs/static/css/style.css
+++ b/hubs/static/css/style.css
@@ -115,6 +115,12 @@ header span.edit {
     }
 
 
+.flash-messages {
+  /* place it on top so they cover content and can be clicked */
+  position: relative;
+  z-index: 3;
+}
+
 .nav-tabs {
   list-style: none;
 }
diff --git a/hubs/templates/errors/hub.html b/hubs/templates/errors/hub.html
index ad55d2a..d2e84c6 100644
--- a/hubs/templates/errors/hub.html
+++ b/hubs/templates/errors/hub.html
@@ -1,4 +1,4 @@
-{% extends "hubs.html" %}
+{% extends "master.html" %}
 
 {% block content %}
 
diff --git a/hubs/templates/hubs.html b/hubs/templates/hubs.html
deleted file mode 100644
index ff6ed6d..0000000
--- a/hubs/templates/hubs.html
+++ /dev/null
@@ -1,340 +0,0 @@
-{% extends "master.html" %}
-
-{% block title %}{{ hub.name }}{% endblock %}
-
-
-{% block header %}
-        
-<div class="row">
-
-  <!-- Left side of header -->
-  <div class="col-md-{{ hub.config.left_width }}">
-    {% if hub.user_hub %}
-      <img src="{{ hub.config.avatar }}" class="avatar" />
-      <h2 class="user m-b-0">{{ hub.name }}</h2>
-      <h5 class="m-t-1 m-b-1">{{ hub.config.summary }}</h5>
-      <div class="clearfix"></div>
-    {% else %}
-      <h2 class="team">{{ hub.name }}</h2>
-      <h5 class="m-t-1 m-b-1">{{ hub.config.summary }}</h5>
-    {% endif %}
-  </div>
-          
-  <!-- right side of header -->
-  <div class="col-md-{{ hub.config.right_width }} text-center align-self-end">
-    {% if hub.allows(g.user, "config") %}
-      <button class="btn btn-sm btn-secondary" data-toggle="modal" data-target="#settings-modal">
-        <i class="fa fa-cog" aria-hidden="true"></i>
-        hub settings
-      </button>
-      {% if not edit %}
-        <a href="{{ url_for('hub_edit', name=hub.name) }}" class="btn btn-sm btn-secondary">
-          <i class="fa fa-pencil" aria-hidden="true"></i>
-          edit page layout
-        </a>
-      {% else %}
-        <button class="btn btn-sm btn-primary" href="#" id="save_edits_btn">
-          <i class="fa fa-download" aria-hidden="true"></i>
-          Save changes
-        </button>
-      {% endif %}
-    {% endif %}
-  </div>
-
-</div>
-        
-{% endblock header %}
-
-
-
-{% block content %}
-
-<div class="row">
-  <div class="col-md-{{ hub.config.left_width }}">
-    <!--
-      First, before the proper widgets.. a cobweb built-in widget.
-      https://pagure.io/fedora-hubs/issue/133
-    -->
-    {% if hub.archived or hub.last_refreshed | days_since > 31 %}
-    <ul class="media-list">
-      <li class="media">
-        <div class="media-left">
-        {% if hub.archived %}
-          <img class="media-object square-32"
-            src="{{ url_for('static', filename='img/archived.png') }}"/>
-        {% else %}
-          <img class="media-object square-32"
-            src="{{ url_for('static', filename='img/cobweb.png') }}"/>
-        {% endif %}
-        </div>
-        <div class="media-body">
-          {% if hub.archived %}
-          This hub has been archived and locked.
-          {% else %}
-          Cobweb alert!  This hub was last active {{
-            hub.last_refreshed | days_since }} days ago.
-          {% endif %}
-        </div>
-      </li>
-    </ul>
-    {% endif %}
-
-    {% if edit %}
-    <div id='add-widget-left' class='widget row'>
-      <div class="card">
-        <div class="card-block">
-          <h4>
-          <a href="{{ url_for("hub_add_widget", name=hub.name) }}?position=left" class="add_widget">
-            <span><i class="fa fa-plus" aria-hidden="true"></i></span> Add a widget
-          </a>
-          </h4>
-        </div>
-      </div>
-    </div>
-    {% endif %}
-
-    <div id="left_widgets">
-      {% for widget in widgets["left"] %}
-      <div id="widget-{{ widget.idx }}" class="widget row"
-           data-url="{{ url_for('%s_root' % widget.plugin, hub=hub.name, idx=widget.idx) }}{% if edit %}?editmode=1{% endif %}"
-           ></div>
-      {% endfor %}
-    </div>
-  </div>
-
-  <div class="col-md-{{ hub.config.right_width }}">
-    {% if edit %}
-    <div id='add-widget-right' class='widget row'>
-      <div class="card">
-        <div class="card-block">
-          <h4>
-          <a href="{{ url_for("hub_add_widget", name=hub.name) }}?position=right" class="add_widget">
-          <span><i class="fa fa-plus" aria-hidden="true"></i></span> Add a widget
-          </a>
-          </h4>
-        </div>
-      </div>
-    </div>
-    {% endif %}
-
-    <div id="right_widgets">
-      {% for widget in widgets["right"] %}
-      <div id="widget-{{ widget.idx }}" class="widget row"
-           data-url="{{ url_for('%s_root' % widget.plugin, hub=hub.name, idx=widget.idx) }}{% if edit %}?editmode=1{% endif %}"
-           ></div>
-      {% endfor %}
-    </div>
-  </div>
-
-</div>  <!-- /row -->
-
-<div id="settings-modal" class="modal fade" tabindex="-1" role="dialog">
-</div>
-
-
-<div id="edit_modal" class="modal fade" role="dialog">
-  <div class="modal-dialog" id="edit_modal_content">
-  </div>
-</div>
-
-{% endblock content %}
-
-
-
-{% block jscripts %}
-{{ super() }}
-
-{% if edit %}
-<script src="{{ url_for('static', filename='js/Sortable.min.js') }}"></script>
-<script>
-
-function make_widget_sortable() {
-  var byId = function (id) { return document.getElementById(id); }
-
-  Sortable.create(byId('left_widgets'), {
-    animation: 150,
-    draggable: '.widget',
-    handle: '.card',
-  });
-
-  Sortable.create(byId('right_widgets'), {
-    animation: 150,
-    draggable: '.widget',
-    handle: '.card',
-  });
-
-};
-
-function setup_add_btns() {
-  // Setup events for the "add widget" button.
-  $(".add_widget").click(function(e) {
-    e.preventDefault();
-    $.ajax({
-      url: $(this).attr('href'),
-      dataType: 'html',
-      success: function(html) {
-        $('#edit_modal_content').html(html);
-      },
-      error: function(html) {
-        $('#edit_modal_content').html(
-          '<div class="modal-dialog"><div class="modal-content"> \
-          <div class="modal-body"> \
-          An error occured when trying to add a new widget\
-          </div></div></div>'
-        );
-        console.log('error');
-        console.trace();
-        console.log(html);
-      },
-      complete: function() {
-        $('#edit_modal').modal();
-      }
-    });
-    return false;
-  });
-  // Setup events for the "add widget" form using delegated events because it's
-  // not in the DOM yet.
-  $("body").on("submit", "#adding_form form", function(e) {
-      var form = $(this);
-      e.preventDefault();
-      $.ajax({
-          type: "POST",
-          url: form.attr("action"),
-          dataType: "json",
-          data: form.serialize(),
-          success: function(data) {
-              if (data.status === "CONFIGURE") {
-                  $("#adding_form").load(data.url);
-              } else if (data.status === "ADDED") {
-                  // no config necessary, reload the page
-                  window.location = window.location;
-              } else {
-                $('#adding_form .modal-body').html(
-                  "An error occured when trying to add a new widget."
-                );
-                $('#adding_form .modal-footer button[type="submit"]').remove();
-                console.log('error');
-                console.trace();
-                console.log(data);
-              }
-          },
-          error: function(html) {
-            $('#adding_form .modal-body').html(
-              "An error occured when trying to add a new widget."
-            );
-            $('#adding_form .modal-footer button[type="submit"]').remove();
-            console.log('error');
-            console.trace();
-            console.log(html);
-          }
-        });
-  });
-}
-
-$('#save_edits_btn').click(function(e) {
-  e.preventDefault();
-  var _r_indexes = [];
-  var _r_widgets = [];
-  $.each($('#right_widgets .widget'), function(i, el) {
-    _r_indexes.push(i);
-    _r_widgets.push($(el).attr('id').split('widget-')[1])
-  });
-
-  var _l_indexes = [];
-  var _l_widgets = [];
-  $.each($('#left_widgets .widget'), function(i, el) {
-    _l_indexes.push(i);
-    _l_widgets.push($(el).attr('id').split('widget-')[1])
-  });
-
-  $.post(
-    '{{ url_for("hub_edit", name=hub.name) }}',
-    {'right_indexes': _r_indexes, 'right_widgets': _r_widgets,
-     'left_indexes': _l_indexes, 'left_widgets': _l_widgets,
-     'js': true},
-    function(){ window.location = '{{ url_for("hub", name=hub.name) }}' }
-  );
-});
-</script>
-{% endif %}
-
-<script type="text/javascript">
-function setup_edit_btns() {
-  $("body").on("click", ".edit_widget", function(e) {
-    e.preventDefault();
-    var url = $(this).attr('data-url');
-
-    $.ajax({
-      url: url,
-      dataType: 'html',
-      success: function(html) {
-        $('#edit_modal_content').html(html);
-        $('#edit_modal').modal();
-      },
-      error: function() {
-        $('#edit_modal_content').html(
-          '<div class="modal-dialog"><div class="modal-content"> \
-          <div class="modal-body"> \
-          Nothing to configure for this widget\
-          </div></div></div>'
-        );
-        console.log('error');
-        console.trace();
-        $('#edit_modal').modal();
-      },
-    });
-    return false;
-  });
-}
-
-function visit_counter() {
-  if (window.performance.navigation.type == 0) {
-    var username = '{{ g.auth.nickname }}'
-    var visited_hub = '{{ hub.name }}'
-    if(username != null && username != '' && username != visited_hub) {
-      url_str = '/visit/' + visited_hub;
-
-      $.ajax({
-        method: "POST",
-        url: url_str,
-        dataType: 'html',
-        success: function(html) {
-          console.log('Success: incrementing counter')
-        },
-        error: function() {
-          console.log('Error: incrementing counter');
-        },
-      });
-    }
-  }
-}
-
-function setup_settings() {
-  var settingsPanel = React.createElement(Hubs.HubConfig, {
-    urls: {
-      config: {{ url_for("hub_config", name=hub.name)|tojson }},
-      suggestUsers: {{ url_for("hub_config_suggest_users", name=hub.name)|tojson }},
-    }
-  });
-  ReactDOM.render(settingsPanel, document.getElementById("settings-modal"));
-}
-
-$(function() {
-  visit_counter()
-
-  setup_widgets();
-  setup_sse({{ sse_url | tojson }});
-  setup_edit_btns();
-  {% if hub.allows(g.user, "config") %}
-  setup_settings();
-  {% endif %}
-
-  {% if edit -%}
-  make_widget_sortable();
-  setup_add_btns();
-  {%- endif %}
-});
-
-</script>
-{% endblock %}
-{# vim: set ts=2 sw=2 et: #}
diff --git a/hubs/templates/master.html b/hubs/templates/master.html
index 8b3706c..1a74f82 100644
--- a/hubs/templates/master.html
+++ b/hubs/templates/master.html
@@ -68,12 +68,15 @@
 </nav>
 
 
+{% block fullcontent %}
+
 <header>
 
+  {% block flash_messages %}
   <!-- Flash messages -->
   {%- with messages = get_flashed_messages(with_categories=true) -%}
   {%- if category, messages -%}
-    <div class="row p-t-2">
+    <div class="row pt-2 flash-messages">
       <div class="col-md-6 offset-md-3">
       {%- for category, message in messages -%}
         <div class="alert {%
@@ -91,6 +94,7 @@
     </div>
   {%- endif -%}
   {%- endwith -%}
+  {% endblock flash_messages %}
 
   <div class="row">
     <div class="offset-lg-2 col-lg-10">
@@ -154,6 +158,7 @@
     </div>
   </div>
 </div> <!-- /.container-fluid -->
+{% endblock fullcontent %}
 
 {% block jscripts %}
 <script src="{{ url_for('static', filename='js/jquery-1.10.2.min.js') }}"></script>
diff --git a/hubs/templates/react.html b/hubs/templates/react.html
new file mode 100644
index 0000000..731b5be
--- /dev/null
+++ b/hubs/templates/react.html
@@ -0,0 +1,27 @@
+{% extends "master.html" %}
+
+{% block title %}{{ page_title }}{% endblock %}
+
+{% block flash_messages %}
+<!-- Flash messages handled in React -->
+{% endblock flash_messages %}
+
+{% block fullcontent %}
+
+<div id="page"></div>
+
+{% endblock fullcontent %}
+
+
+
+{% block jscripts %}
+{{ super() }}
+
+<script>
+(function() {
+  Hubs.setupPage("page", {{ initial_state|tojson }});
+})();
+</script>
+{% endblock %}
+
+{# vim: set ts=2 sw=2 et: #}
diff --git a/hubs/tests/utils/test_views.py b/hubs/tests/utils/test_views.py
index 9c4f54f..13d9c1c 100644
--- a/hubs/tests/utils/test_views.py
+++ b/hubs/tests/utils/test_views.py
@@ -2,10 +2,10 @@ from __future__ import unicode_literals
 
 import flask
 
-import hubs.models
+from hubs.models import Hub, Widget
 from hubs.app import app
-from hubs.tests import APPTest, FakeAuthorization, auth_set, app_config
-from hubs.utils.views import get_visible_widgets, get_sse_url, move_widget
+from hubs.tests import APPTest, app_config
+from hubs.utils.views import get_sse_url, move_widget
 
 
 class ViewUtilsTest(APPTest):
@@ -19,74 +19,6 @@ class ViewUtilsTest(APPTest):
         self.app_context.__exit__(None, None, None)
         super(ViewUtilsTest, self).tearDown()
 
-    def test_removed_widget(self):
-        hub = hubs.models.Hub.get("ralph")
-        widget = hubs.models.Widget(
-            hub=hub, plugin="does-not-exist",
-            left=True, index=-1, _config="{}")
-        self.session.add(widget)
-        with auth_set(app, FakeAuthorization('ralph')):
-            widgets = get_visible_widgets(hub)
-        self.assertNotIn(
-            "does-not-exist", [w.plugin for w in widgets["left"]])
-        widget.left = False
-        self.assertNotIn(
-            "does-not-exist", [w.plugin for w in widgets["right"]])
-
-    def test_get_visible_widgets(self):
-        hub = hubs.models.Hub.get('ralph')
-        with auth_set(app, None):
-            widgets = get_visible_widgets(hub)
-        self.assertListEqual(
-            [w.plugin for w in widgets["left"]],
-            ["feed"])
-        self.assertListEqual(
-            [w.plugin for w in widgets["right"]],
-            ['contact', 'meetings', 'fedmsgstats', 'workflow.updates2stable',
-             'subscriptions', 'badges', 'pagure_pr', 'github_pr', 'bugzilla',
-             'halp', 'about']
-            )
-
-    def test_get_visible_widgets_preview(self):
-        hub = hubs.models.Hub.by_name('ralph')
-        hub.config.visibility = "preview"
-        meetings = hubs.models.Widget.query.filter_by(
-            hub=hub, plugin="meetings").first()
-        # make sure the test is valid
-        assert meetings.left is False
-        # set the widget to "restricted"
-        meetings.visibility = "restricted"
-        # Restricted widgets in preview hubs aren't displayed to
-        # anonymous users.
-        with auth_set(app, None):
-            widgets = get_visible_widgets(hub)
-        self.assertNotIn(
-            "meetings", [w.plugin for w in widgets["right"]])
-        # But they are displayed to members.
-        with auth_set(app, FakeAuthorization('ralph')):
-            widgets = get_visible_widgets(hub)
-        self.assertIn(
-            "meetings", [w.plugin for w in widgets["right"]])
-
-    def test_get_visible_widgets_private(self):
-        hub = hubs.models.Hub.by_name('ralph')
-        hub.config.visibility = "private"
-        meetings = hubs.models.Widget.query.filter_by(
-            hub=hub, plugin="meetings").first()
-        # make sure the test is valid
-        assert meetings.left is False
-        # Widgets in private hubs aren't displayed to
-        # anonymous users.
-        with auth_set(app, None):
-            widgets = get_visible_widgets(hub)
-        self.assertEqual(widgets["left"], [])
-        self.assertEqual(widgets["right"], [])
-        # But they are displayed to members.
-        with auth_set(app, FakeAuthorization('ralph')):
-            widgets = get_visible_widgets(hub)
-        self.assertNotEqual(len(widgets["left"]), 0)
-        self.assertNotEqual(len(widgets["right"]), 0)
-
     def test_sse_url(self):
         with app.test_request_context():
             self.assertEqual(get_sse_url(""), "http://localhost:8080/sse/")
@@ -131,18 +63,18 @@ class ViewUtilsTest(APPTest):
                     "http://example.com/hub/ralph")
 
     def test_move_widget(self):
-        hub = hubs.models.Hub(name="testing")
+        hub = Hub(name="testing")
         self.session.add(hub)
         widget_names = ["about", "badges", "bugzilla", "contact", "dummy"]
 
         def get_names():
             return [
                 w.plugin for w in
-                hubs.models.Widget.query.filter_by(hub=hub).order_by(hubs.models.Widget.index)
+                Widget.query.filter_by(hub=hub).order_by(Widget.index)
                 ]
 
         def get_widget(name):
-            return hubs.models.Widget.query.filter_by(hub=hub, plugin=name).one()
+            return Widget.query.filter_by(hub=hub, plugin=name).one()
 
         def check_move(name, position, expected):
             move_widget(get_widget(name), position)
@@ -151,7 +83,7 @@ class ViewUtilsTest(APPTest):
             self.assertListEqual(get_names(), expected)
 
         for index, name in enumerate(widget_names):
-            widget = hubs.models.Widget(
+            widget = Widget(
                 hub=hub, plugin=name,
                 left=True, index=index, _config="{}")
             self.session.add(widget)
diff --git a/hubs/tests/views/test_hub_view.py b/hubs/tests/views/test_hub_view.py
index 68b822c..be57081 100644
--- a/hubs/tests/views/test_hub_view.py
+++ b/hubs/tests/views/test_hub_view.py
@@ -1,91 +1,46 @@
 from __future__ import unicode_literals
 
-from flask import json
-
-from hubs.app import app
-from hubs.models import Hub
-from hubs.tests import APPTest, FakeAuthorization, auth_set
+from hubs.models import Hub, User
+from hubs.tests import APPTest, FakeAuthorization
 
 
 class HubViewTestCase(APPTest):
 
     def test_hub_logged_out(self):
-        with auth_set(app, None):  # check_auth doesn't load in unittest
-            result = self.app.get('/ralph', follow_redirects=True)
-            self.assertEqual(result.status_code, 200)
-            match_expected = r'<h5[^>]*>Ralph</h5>'
-            self.assertRegexpMatches(
-                result.get_data(as_text=True), match_expected)
-            str_expected = 'Not logged in.'
-            self.assertIn(str_expected, result.get_data(as_text=True))
+        response = self.check_url("/ralph/")
+        self.assertIn("Not logged in.", response.get_data(as_text=True))
+        self.assertEqual(response.context["page_title"], "ralph")
+        state = response.context["initial_state"]
+        self.assertEqual(state["urls"]["hubConfig"], "/api/hubs/ralph/config")
+        self.assertFalse(state["currentUser"]["logged_in"])
+        self.assertFalse(state["currentUser"]["can_config_hub"])
+        self.assertFalse(state["currentUser"]["can_manage_users"])
 
     def test_hub_logged_in(self):
         user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            result = self.app.get('/ralph', follow_redirects=True)
-            self.assertEqual(result.status_code, 200)
-            self.assertNotIn(
-                'Not logged in.  Click to <a href="/login">login</a>',
-                result.get_data(as_text=True))
+        response = self.check_url("/ralph/", user=user)
+        self.assertNotIn(
+            'Not logged in.  Click to <a href="/login">login</a>',
+            response.get_data(as_text=True))
+        state = response.context["initial_state"]
+        self.assertTrue(state["currentUser"]["logged_in"])
+        self.assertTrue(state["currentUser"]["can_config_hub"])
+        self.assertTrue(state["currentUser"]["can_manage_users"])
 
     def test_hub_preview(self):
         hub = Hub.by_name('ralph')
         hub.config.visibility = "preview"
         # Preview hubs are accessible to anonymous users (but some
         # widgets may be restricted).
-        with auth_set(app, None):
-            result = self.app.get('/ralph', follow_redirects=True)
-            self.assertEqual(result.status_code, 200)
+        self.check_url("/ralph/")
 
     def test_hub_private(self):
         hub = Hub.by_name('ralph')
         hub.config.visibility = "private"
         self.session.commit()
         # Private hubs are not accessible to anonymous users.
-        with auth_set(app, None):
-            result = self.app.get('/ralph', follow_redirects=True)
-            self.assertEqual(result.status_code, 403)
+        self.check_url("/ralph/", code=403)
         # But they are accessible to members.
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            result = self.app.get('/ralph', follow_redirects=True)
-            self.assertEqual(result.status_code, 200)
-
-    def test_hub_json(self):
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            result = self.app.get('/ralph/json', follow_redirects=True)
-        # assert the status code of the response
-        self.assertEqual(result.status_code, 200)
-        data = {
-            "config": {
-                "avatar": "https://seccdn.libravatar.org/avatar/"
-                          "9c9f7784935381befc302fe3c814f9136e7a339"
-                          "53d0318761669b8643f4df55c?s=312&d=retro",
-                'chat_channel': None,
-                'chat_domain': None,
-                "left_width": 8,
-                "summary": "Ralph",
-                "visibility": "public",
-            },
-            "members": ["ralph"],
-            "name": "ralph",
-            "owners": ["ralph"],
-            "subscribers": [],
-            "widgets": [34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 56],
-        }
-        self.assertDictEqual(data, json.loads(result.get_data(as_text=True)))
-
-    def test_hub_private_json(self):
-        hub = Hub.by_name('ralph')
-        hub.config.visibility = "private"
-        self.session.commit()
-        # Private hubs are not accessible to anonymous users.
-        with auth_set(app, None):
-            result = self.app.get('/ralph/json', follow_redirects=True)
-            self.assertEqual(result.status_code, 403)
-        # But they are accessible to members.
-        user = FakeAuthorization('ralph')
-        with auth_set(app, user):
-            result = self.app.get('/ralph/json', follow_redirects=True)
-            self.assertEqual(result.status_code, 200)
+        decause = User.by_username("decause")
+        hub.subscribe(decause, role='member')
+        self.check_url("/ralph/", user=FakeAuthorization("decause"))
diff --git a/hubs/utils/views.py b/hubs/utils/views.py
index 36df575..2445387 100644
--- a/hubs/utils/views.py
+++ b/hubs/utils/views.py
@@ -49,25 +49,34 @@ def get_widget_instance(hub, idx, session=None):
         flask.abort(404)
 
 
-def get_visible_widgets(hub):
-    from hubs.widgets import registry
-    widgets = {"left": [], "right": []}
-    try:
+def get_menu_entries():
+    """Get the entries for the left menu."""
+    menu = []
+    if flask.g.auth.logged_in:
         user = flask.g.user
-    except AttributeError:
-        user = None
-    for widget in hub.widgets:
-        if widget.plugin not in registry:
-            continue  # disabled widget
-        if not widget.allows(user, "view"):
-            continue
-        if widget.left:
-            widgets["left"].append(widget)
-        else:
-            widgets["right"].append(widget)
-    widgets["left"].sort(key=lambda w: w.index)
-    widgets["right"].sort(key=lambda w: w.index)
-    return widgets
+        menu.append({
+            "url": flask.url_for("hub", name=user.username),
+            "icon": "home",
+            "text": "My Hub",
+        })
+        menu.append({
+            "url": flask.url_for("stream"),
+            "icon": "home",
+            "text": "My Stream",
+        })
+        for hub in user.bookmarks:
+            menu.append({
+                "url": flask.url_for("hub", name=hub.name),
+                "icon": "bookmark",
+                "text": hub.name,
+                "cssClass": "idle-{}".format(hub.activity_class),
+            })
+    menu.append({
+        "url": flask.url_for("groups"),
+        "icon": "users",
+        "text": "All groups",
+    })
+    return menu
 
 
 class WidgetConfigError(Exception):
diff --git a/hubs/views/hub.py b/hubs/views/hub.py
index 6677a7e..9dcd3d5 100644
--- a/hubs/views/hub.py
+++ b/hubs/views/hub.py
@@ -5,20 +5,49 @@ import hubs.models
 
 from hubs.app import app
 from hubs.utils.views import (
-    get_hub, get_sse_url, get_menu_entries, get_visible_widgets, require_hub_access,
+    get_hub, get_sse_url, get_menu_entries, require_hub_access,
     )
 
 
-@app.route('/<name>')
 @app.route('/<name>/')
 @require_hub_access("view")
 def hub(name):
     hub = get_hub(name, load_config=True)
-    widgets = get_visible_widgets(hub)
+    global_config = {
+        "chat_networks": app.config["CHAT_NETWORKS"],
+        "hub_visibility": hubs.models.HubConfig.VISIBILITY,
+        "roles": ["owner", "member"],
+    }
+    urls = {
+        "widgets": flask.url_for("api_hub_widgets", hub=hub.name),
+        "availableWidgets": flask.url_for("api_widgets"),
+        "sse": get_sse_url("hub/{}".format(hub.name)),
+        "hubConfig": flask.url_for("api_hub_config", name=hub.name),
+        "hubConfigSuggestUsers": flask.url_for(
+            "api_hub_config_suggest_users", name=hub.name),
+    }
+    current_user = flask.g.auth.copy()
+    current_user.update({
+        "can_view_hub": hub.allows(flask.g.user, "view"),
+        "can_config_hub": hub.allows(flask.g.user, "config"),
+        "can_manage_users": hub.allows(flask.g.user, "users.manage"),
+    })
+    menu = get_menu_entries()
+    flash_messages = [
+        {"msg": msg[1], "type": msg[0]} for msg in
+        flask.get_flashed_messages(with_categories=True)
+        ]
     return flask.render_template(
-        'hubs.html',
-        hub=hub,
-        widgets=widgets,
-        edit=False,
-        sse_url=get_sse_url("hub/{}".format(hub.name)),
+        'react.html',
+        page_title=hub.name,
+        initial_state=dict(
+            ui=dict(
+                page="Hub",
+                flashMessages=flash_messages,
+                leftMenu=menu,
+            ),
+            globalConfig=global_config,
+            urls=urls,
+            currentUser=current_user,
+            ),
         )

From 97d3d53db5a71c8380e75b2aa4214421156cd588 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Sep 28 2017 17:35:09 +0000
Subject: [PATCH 13/20] React-based widgets are allowed to have no root view


---

diff --git a/hubs/tests/widgets/test_halp.py b/hubs/tests/widgets/test_halp.py
index f160993..53c27f6 100644
--- a/hubs/tests/widgets/test_halp.py
+++ b/hubs/tests/widgets/test_halp.py
@@ -3,8 +3,8 @@ from __future__ import unicode_literals, absolute_import
 import json
 
 from hubs.app import app
-from hubs.models import Hub, User
-from hubs.tests import FakeAuthorization, widget_instance
+from hubs.models import Hub
+from hubs.tests import widget_instance
 from hubs.widgets import registry
 from . import WidgetTest
 
@@ -110,13 +110,6 @@ class HalpViewsTestCase(WidgetTest):
                 })
         return data
 
-    def test_root(self):
-        response = self.check_url('/ralph/w/halp/%i/' % self.widget.idx)
-        self.assertEqual(response.context["hubs"], ['fedora-devel'])
-
-    def test_view_authz(self):
-        self._test_view_authz()
-
     def test_props(self):
         with app.test_request_context('/'):
             props = self.widget.get_props()
diff --git a/hubs/widgets/__init__.py b/hubs/widgets/__init__.py
index be454bd..5dd0a8d 100644
--- a/hubs/widgets/__init__.py
+++ b/hubs/widgets/__init__.py
@@ -27,7 +27,7 @@ In order to be valid, a widget must:
 - have a valid :py:attr:`~hubs.widgets.base.Widget.name` attribute,
 - have a valid :py:attr:`~hubs.widgets.base.Widget.position` attribute,
 - contain at least one widget view named "``root``", registered for the "``/``"
-  URL.
+  URL, unless the widget is React-based.
 
 A widget view is a class which inherits from the
 :py:class:`hubs.widgets.view.WidgetView` class.  A widget can have one or more
diff --git a/hubs/widgets/base.py b/hubs/widgets/base.py
index 5523385..db63192 100644
--- a/hubs/widgets/base.py
+++ b/hubs/widgets/base.py
@@ -116,12 +116,14 @@ class Widget(object):
                 )
         root_view = self.get_views().get("root")
         if root_view is None:
-            raise AttributeError(
-                'widgets must have a "root" view, please refer to the '
-                'documentation.')
-        if "/" not in root_view.url_rules:
-            raise AttributeError(
-                'the root view must be registered for the "/" URL rule.')
+            if not self.is_react:
+                raise AttributeError(
+                    'widgets must have a "root" view, please refer to the '
+                    'documentation.')
+        else:
+            if "/" not in root_view.url_rules:
+                raise AttributeError(
+                    'the root view must be registered for the "/" URL rule.')
 
     def get_parameters(self):
         """
diff --git a/hubs/widgets/feed/__init__.py b/hubs/widgets/feed/__init__.py
index a9a265d..d6c4c61 100644
--- a/hubs/widgets/feed/__init__.py
+++ b/hubs/widgets/feed/__init__.py
@@ -8,7 +8,7 @@ import flask
 from hubs.feed import format_msgs
 from hubs.widgets import validators
 from hubs.widgets.base import Widget
-from hubs.widgets.view import WidgetView, RootWidgetView
+from hubs.widgets.view import WidgetView
 
 from .functions import GetData
 
@@ -43,15 +43,6 @@ class Feed(Widget):
         return props
 
 
-class BaseView(RootWidgetView):
-
-    def get_context(self, instance, *args, **kwargs):
-        return dict(
-            title=self.widget.label,
-            disable_autoreload=True,
-            )
-
-
 class ExistingView(WidgetView):
 
     name = "existing"
diff --git a/hubs/widgets/feed/templates/root.html b/hubs/widgets/feed/templates/root.html
deleted file mode 100644
index f687dd4..0000000
--- a/hubs/widgets/feed/templates/root.html
+++ /dev/null
@@ -1,18 +0,0 @@
-{% extends "panel.html" %}
-
-{% block content %}
-<div id="feed-{{ widget_instance.idx }}">
-</div>
-
-<script type="text/javascript"
-  src ="{{ url_for('static', filename='js/build/Feed.js') }}"></script>
-<script>
-(function() {
-    const FeedElement = React.createElement(Feed.Widget, {
-        url: {{ url_for("feed_existing", hub=widget_instance.hub.name, idx=widget_instance.idx) | tojson }},
-        widgetIdx: {{ widget_instance.idx | tojson }}
-    });
-    ReactDOM.render(FeedElement, document.getElementById('feed-' + {{ widget_instance.idx | tojson }}));
-})();
-</script>
-{% endblock %}
diff --git a/hubs/widgets/halp/templates/root.html b/hubs/widgets/halp/templates/root.html
deleted file mode 100644
index 8709b0e..0000000
--- a/hubs/widgets/halp/templates/root.html
+++ /dev/null
@@ -1,22 +0,0 @@
-{% extends "panel.html" %}
-
-{% block content %}
-<div id="widget-halp">
-</div>
-<script src="{{url_for('static', filename='js/build/Halp.js')}}"></script>
-<script>
-(function() {
-    const Widget = React.createElement(Halp.Widget, {
-        hubs: {{ hubs|tojson }},
-        urls: {
-            data: '{{ url_for("halp_data", hub=widget_instance.hub.name, idx=widget_instance.idx) }}',
-            search: '{{ url_for("halp_search", hub=widget_instance.hub.name, idx=widget_instance.idx) }}',
-            requesters: '{{ url_for("halp_requesters", hub=widget_instance.hub.name, idx=widget_instance.idx) }}',
-            spinner: '{{ url_for("static", filename="img/spinner.gif") }}',
-            spinnerCircle: '{{ url_for("static", filename="img/spinner-circle.gif") }}'
-        }
-    });
-    ReactDOM.render(Widget, document.getElementById('widget-halp'));
-})();
-</script>
-{% endblock %}
diff --git a/hubs/widgets/halp/views.py b/hubs/widgets/halp/views.py
index dfd1e30..184140f 100644
--- a/hubs/widgets/halp/views.py
+++ b/hubs/widgets/halp/views.py
@@ -4,12 +4,9 @@ import datetime
 import time
 
 import flask
-from flask.signals import template_rendered
 
 from hubs.models import Hub
-from hubs.utils.views import get_hub, require_hub_access
-from hubs.widgets import registry
-from hubs.widgets.view import WidgetView, RootWidgetView
+from hubs.widgets.view import WidgetView
 from .functions import GetRequests
 from .utils import find_hubs_for_msg, paginate
 
@@ -19,17 +16,6 @@ except ImportError:
     before_render_template = None  # Flask < 0.11
 
 
-class BaseView(RootWidgetView):
-    """The base view to instantiate the widget."""
-
-    def get_context(self, instance, *args, **kwargs):
-        return dict(
-            hubs=instance.config.get("hubs", []),
-            title=self.widget.label,
-            disable_autoreload=True,
-            )
-
-
 class DataView(WidgetView):
     """Get the requests in JSON format."""
 
diff --git a/hubs/widgets/view.py b/hubs/widgets/view.py
index fce34b3..86dd615 100644
--- a/hubs/widgets/view.py
+++ b/hubs/widgets/view.py
@@ -22,7 +22,8 @@ class WidgetView(View):
     - the :py:meth:`.get_context` method must be implemented
 
     Every widget must have a view with the name "``root``" registered to
-    ``['/']``, which will be the main entry point for the widget.
+    ``['/']`` (unless they are React-based). This view will be the main entry
+    point for the widget.
 
     The resulting endpoint will be composed using the widget name and the view
     name as ``<widget_name>_<view_name>``, for example ``meetings_root``.

From e572637bb33473546262df0ba892f4254727ba45 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Sep 28 2017 17:35:09 +0000
Subject: [PATCH 14/20] Make the Halp REST API similar to the main REST API


---

diff --git a/hubs/tests/widgets/test_halp.py b/hubs/tests/widgets/test_halp.py
index 53c27f6..715fa78 100644
--- a/hubs/tests/widgets/test_halp.py
+++ b/hubs/tests/widgets/test_halp.py
@@ -152,13 +152,15 @@ class HalpViewsTestCase(WidgetTest):
         self.assertEqual(response.status_code, 200)
         data = json.loads(response.get_data(as_text=True))
         self.assertDictEqual(data, {
-            'results': ['decause', 'devyani7', 'dhrish', 'i18n', 'infra']
+            'status': 'OK',
+            'data': ['decause', 'devyani7', 'dhrish', 'i18n', 'infra']
         })
         response = self.app.get('/w/halp/hubs?q=de')
         self.assertEqual(response.status_code, 200)
         data = json.loads(response.get_data(as_text=True))
         self.assertDictEqual(data, {
-            'results': ['decause', 'devyani7']
+            'status': 'OK',
+            'data': ['decause', 'devyani7']
         })
 
     def test_data(self):
@@ -167,13 +169,19 @@ class HalpViewsTestCase(WidgetTest):
         response = self.app.get('/ralph/w/halp/%i/data' % self.widget.idx)
         self.assertEqual(response.status_code, 200)
         data = json.loads(response.get_data(as_text=True))
-        self.assertEqual(data, {"requests": expected[:3]})
+        self.assertEqual(data, {
+            'status': 'OK',
+            "data": {"requests": expected[:3]}
+        })
         # Test with the right hub selected
         response = self.app.get('/ralph/w/halp/%i/data?hubs=decause'
                                 % self.widget.idx)
         self.assertEqual(response.status_code, 200)
         data = json.loads(response.get_data(as_text=True))
-        self.assertEqual(data, {"requests": expected[3:5]})
+        self.assertEqual(data, {
+            'status': 'OK',
+            "data": {"requests": expected[3:5]},
+        })
 
     def test_data_wrong_hub(self):
         # Test with the wrong hub selected
@@ -181,7 +189,9 @@ class HalpViewsTestCase(WidgetTest):
                                 % self.widget.idx)
         self.assertEqual(response.status_code, 200)
         data = json.loads(response.get_data(as_text=True))
-        self.assertEqual(data, {"requests": []})
+        self.assertEqual(data, {
+            'status': 'OK', "data": {"requests": []}
+        })
 
     def test_search_requesters(self):
         response = self.app.get(
@@ -189,15 +199,16 @@ class HalpViewsTestCase(WidgetTest):
         self.assertEqual(response.status_code, 200)
         data = json.loads(response.get_data(as_text=True))
         self.assertDictEqual(data, {
-            'results': ['jborean93', 'jkurik', 'mattdm', 'nitzmahone',
-                        'stoney']
-        })
+            'status': 'OK',
+            'data': ['jborean93', 'jkurik', 'mattdm', 'nitzmahone', 'stoney']
+            })
         response = self.app.get(
             '/ralph/w/halp/%i/requesters?q=j' % self.widget.idx)
         self.assertEqual(response.status_code, 200)
         data = json.loads(response.get_data(as_text=True))
         self.assertDictEqual(data, {
-            'results': ['jborean93', 'jkurik']
+            'status': 'OK',
+            'data': ['jborean93', 'jkurik']
         })
 
     def test_search_all(self):
@@ -207,7 +218,8 @@ class HalpViewsTestCase(WidgetTest):
         self.assertEqual(response.status_code, 200)
         data = json.loads(response.get_data(as_text=True))
         self.assertEqual(data, {
-            "requests": expected[:3],
+            'status': 'OK',
+            'data': {"requests": expected[:3]},
             'page': {
                 'has_next': True,
                 'has_prev': False,
@@ -222,7 +234,8 @@ class HalpViewsTestCase(WidgetTest):
         self.assertEqual(response.status_code, 200)
         data = json.loads(response.get_data(as_text=True))
         self.assertEqual(data, {
-            "requests": expected[3:],
+            'status': 'OK',
+            'data': {"requests": expected[3:]},
             'page': {
                 'has_next': False,
                 'has_prev': True,
@@ -239,7 +252,8 @@ class HalpViewsTestCase(WidgetTest):
         data = json.loads(response.get_data(as_text=True))
         expected = self._get_sample_data_with_hub_keys()
         self.assertEqual(data, {
-            "requests": expected[0:1],
+            'status': 'OK',
+            "data": {"requests": expected[0:1]},
             'page': {
                 'has_next': False,
                 'has_prev': False,
@@ -256,7 +270,8 @@ class HalpViewsTestCase(WidgetTest):
         data = json.loads(response.get_data(as_text=True))
         expected = self._get_sample_data_with_hub_keys()
         self.assertEqual(data, {
-            "requests": expected[3:4],
+            'status': 'OK',
+            "data": {"requests": expected[3:4]},
             'page': {
                 'has_next': False,
                 'has_prev': False,
@@ -273,7 +288,8 @@ class HalpViewsTestCase(WidgetTest):
         data = json.loads(response.get_data(as_text=True))
         expected = self._get_sample_data_with_hub_keys()
         self.assertEqual(data, {
-            "requests": expected[1:2],
+            'status': 'OK',
+            "data": {"requests": expected[1:2]},
             'page': {
                 'has_next': False,
                 'has_prev': False,
@@ -291,7 +307,8 @@ class HalpViewsTestCase(WidgetTest):
         data = json.loads(response.get_data(as_text=True))
         expected = self._get_sample_data_with_hub_keys()
         self.assertEqual(data, {
-            "requests": expected[1:2],
+            'status': 'OK',
+            "data": {"requests": expected[1:2]},
             'page': {
                 'has_next': False,
                 'has_prev': False,
diff --git a/hubs/widgets/halp/functions.py b/hubs/widgets/halp/functions.py
index 0191710..c99f611 100644
--- a/hubs/widgets/halp/functions.py
+++ b/hubs/widgets/halp/functions.py
@@ -29,7 +29,6 @@ class GetRequests(CachedFunction):
     TOPIC = "org.fedoraproject.prod.meetbot.meeting.item.help"
 
     def execute(self):
-        # return [_format_message(msg) for msg in EXAMPLE_DATA]
         raw_messages = datagrepper_get({
             "topic": self.TOPIC,
             "delta": str(86400 * 10),  # 10 days
diff --git a/hubs/widgets/halp/views.py b/hubs/widgets/halp/views.py
index 184140f..d8a0c41 100644
--- a/hubs/widgets/halp/views.py
+++ b/hubs/widgets/halp/views.py
@@ -24,12 +24,15 @@ class DataView(WidgetView):
     json = True
 
     def get_context(self, instance, *args, **kwargs):
-        get_requests = GetRequests(instance)
         data = {"requests": []}
+        get_requests = GetRequests(instance)
         requests = get_requests() or []
+        # from .functions import _format_message, EXAMPLE_DATA
+        # requests = [_format_message(msg) for msg in EXAMPLE_DATA]
         hubs_filter = flask.request.args.getlist("hubs")
         for req in requests:
             req["hubs"] = find_hubs_for_msg(req)
+            # req["hubs"] = ["infrastructure", "commops"]
             if not req["hubs"]:
                 continue
             req["hub"] = req["hubs"][0]  # Arbitrarily assign the first hub.
@@ -44,7 +47,7 @@ class DataView(WidgetView):
             if len(data["requests"]) == 3:
                 # Only 3 requests on the main widget.
                 break
-        return data
+        return dict(status="OK", data=data)
 
 
 class SearchView(WidgetView):
@@ -101,8 +104,7 @@ class SearchView(WidgetView):
         requests, page_data = paginate(
             data["requests"], instance.config["per_page"])
         data["requests"] = requests
-        data["page"] = page_data
-        return data
+        return dict(status="OK", data=data, page=page_data)
 
 
 class RequestersView(WidgetView):
@@ -123,7 +125,7 @@ class RequestersView(WidgetView):
         if query:
             results = [name for name in results if name.startswith(query)]
         results = sorted(list(results))[:self.MAX_SUGGESTS]
-        return {"results": results}
+        return {"status": "OK", "data": results}
 
 
 def hubs_suggest_view():
@@ -136,4 +138,4 @@ def hubs_suggest_view():
         results = results.filter(Hub.name.ilike(
             "%s%%" % flask.request.args.get("q")))
     results = results.order_by(Hub.name)[:MAX_SUGGESTS]
-    return flask.jsonify({"results": [h.name for h in results]})
+    return flask.jsonify({"status": "OK", "data": [h.name for h in results]})

From 10aac805c08c356183fd4113331d659ce394e00d Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Sep 28 2017 17:35:09 +0000
Subject: [PATCH 15/20] Make the Streams page use React too


---

diff --git a/hubs/templates/stream.html b/hubs/templates/stream.html
deleted file mode 100644
index 4292f6d..0000000
--- a/hubs/templates/stream.html
+++ /dev/null
@@ -1,43 +0,0 @@
-{% extends "master.html" %}
-
-{% block title %}My Stream{% endblock %}
-
-
-{% block header %}
-  <h1>My Stream</h1>
-  <p>Notifications, actions, and other things of interest to me</p>
-{% endblock header %}
-
-
-{% block content %}
-
-<div class="row">
-  <div class="col-sm-8">
-    <div id="streams">
-    </div>
-  </div>
-
-  <div class="col-sm-4">
-    <!-- put some content here! :) -->
-  </div>
-</div>
-
-{% endblock content %}
-
-
-{% block jscripts %}
-{{ super() }}
-<script>
- $(function() {
-    setup_sse({{ sse_url | tojson }});
-    const StreamsElement = React.createElement(Hubs.Streams, {
-        username: {{ g.auth.nickname | tojson }},
-        urls: {
-            saved: {{ url_for("saved_notifs") | tojson }},
-            content: {{ url_for("stream_existing") | tojson }},
-        },
-    });
-    ReactDOM.render(StreamsElement, document.getElementById('streams'));
- });
-</script>
-{% endblock %}
diff --git a/hubs/views/user.py b/hubs/views/user.py
index c0e2b99..02b2e54 100644
--- a/hubs/views/user.py
+++ b/hubs/views/user.py
@@ -5,20 +5,38 @@ import hubs.models
 import hubs.feed
 
 from hubs.app import app
-from hubs.utils.views import login_required, get_hub, get_sse_url
+from hubs.utils.views import (
+    login_required, get_sse_url, get_menu_entries)
 
 
 @app.route('/stream')
 @app.route('/stream/')
 @login_required
 def stream():
-    username = flask.g.user.username
-    hub = get_hub(username)
+    current_user = flask.g.auth.copy()
+    urls = {
+        "sse": get_sse_url("user/{}".format(current_user.nickname)),
+        "notifications": flask.url_for("stream_existing"),
+        "saved": flask.url_for("saved_notifs"),
+    }
+    menu = get_menu_entries()
+    flash_messages = [
+        {"msg": msg[1], "type": msg[0]} for msg in
+        flask.get_flashed_messages(with_categories=True)
+        ]
     return flask.render_template(
-        'stream.html',
-        hub=hub,
-        sse_url=get_sse_url("user/{}".format(username)),
-    )
+        'react.html',
+        page_title="My Stream",
+        initial_state=dict(
+            ui=dict(
+                page="Streams",
+                flashMessages=flash_messages,
+                leftMenu=menu,
+            ),
+            urls=urls,
+            currentUser=current_user,
+            ),
+        )
 
 
 @app.route('/stream/existing')

From d01121338496a18d86faba39e0a54c2316d07267 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Sep 28 2017 17:35:09 +0000
Subject: [PATCH 16/20] Remove dead JS code


---

diff --git a/hubs/static/js/Sortable.min.js b/hubs/static/js/Sortable.min.js
deleted file mode 100644
index e95d2a3..0000000
--- a/hubs/static/js/Sortable.min.js
+++ /dev/null
@@ -1,2 +0,0 @@
-/*! Sortable 1.4.2 - MIT | git://github.com/rubaxa/Sortable.git */
-!function(a){"use strict";"function"==typeof define&&define.amd?define(a):"undefined"!=typeof module&&"undefined"!=typeof module.exports?module.exports=a():"undefined"!=typeof Package?Sortable=a():window.Sortable=a()}(function(){"use strict";function a(a,b){if(!a||!a.nodeType||1!==a.nodeType)throw"Sortable: `el` must be HTMLElement, and not "+{}.toString.call(a);this.el=a,this.options=b=r({},b),a[L]=this;var c={group:Math.random(),sort:!0,disabled:!1,store:null,handle:null,scroll:!0,scrollSensitivity:30,scrollSpeed:10,draggable:/[uo]l/i.test(a.nodeName)?"li":">*",ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",ignore:"a, img",filter:null,animation:0,setData:function(a,b){a.setData("Text",b.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1};for(var d in c)!(d in b)&&(b[d]=c[d]);V(b);for(var f in this)"_"===f.charAt(0)&&(this[f]=this[f].bind(this));this.nativeDraggable=b.forceFallback?!1:P,e(a,"mousedown",this._onTapStart),e(a,"touchstart",this._onTapStart),this.nativeDraggable&&(e(a,"dragover",this),e(a,"dragenter",this)),T.push(this._onDragOver),b.store&&this.sort(b.store.get(this))}function b(a){v&&v.state!==a&&(h(v,"display",a?"none":""),!a&&v.state&&w.insertBefore(v,s),v.state=a)}function c(a,b,c){if(a){c=c||N,b=b.split(".");var d=b.shift().toUpperCase(),e=new RegExp("\\s("+b.join("|")+")(?=\\s)","g");do if(">*"===d&&a.parentNode===c||(""===d||a.nodeName.toUpperCase()==d)&&(!b.length||((" "+a.className+" ").match(e)||[]).length==b.length))return a;while(a!==c&&(a=a.parentNode))}return null}function d(a){a.dataTransfer&&(a.dataTransfer.dropEffect="move"),a.preventDefault()}function e(a,b,c){a.addEventListener(b,c,!1)}function f(a,b,c){a.removeEventListener(b,c,!1)}function g(a,b,c){if(a)if(a.classList)a.classList[c?"add":"remove"](b);else{var d=(" "+a.className+" ").replace(K," ").replace(" "+b+" "," ");a.className=(d+(c?" "+b:"")).replace(K," ")}}function h(a,b,c){var d=a&&a.style;if(d){if(void 0===c)return N.defaultView&&N.defaultView.getComputedStyle?c=N.defaultView.getComputedStyle(a,""):a.currentStyle&&(c=a.currentStyle),void 0===b?c:c[b];b in d||(b="-webkit-"+b),d[b]=c+("string"==typeof c?"":"px")}}function i(a,b,c){if(a){var d=a.getElementsByTagName(b),e=0,f=d.length;if(c)for(;f>e;e++)c(d[e],e);return d}return[]}function j(a,b,c,d,e,f,g){var h=N.createEvent("Event"),i=(a||b[L]).options,j="on"+c.charAt(0).toUpperCase()+c.substr(1);h.initEvent(c,!0,!0),h.to=b,h.from=e||b,h.item=d||b,h.clone=v,h.oldIndex=f,h.newIndex=g,b.dispatchEvent(h),i[j]&&i[j].call(a,h)}function k(a,b,c,d,e,f){var g,h,i=a[L],j=i.options.onMove;return g=N.createEvent("Event"),g.initEvent("move",!0,!0),g.to=b,g.from=a,g.dragged=c,g.draggedRect=d,g.related=e||b,g.relatedRect=f||b.getBoundingClientRect(),a.dispatchEvent(g),j&&(h=j.call(i,g)),h}function l(a){a.draggable=!1}function m(){R=!1}function n(a,b){var c=a.lastElementChild,d=c.getBoundingClientRect();return(b.clientY-(d.top+d.height)>5||b.clientX-(d.right+d.width)>5)&&c}function o(a){for(var b=a.tagName+a.className+a.src+a.href+a.textContent,c=b.length,d=0;c--;)d+=b.charCodeAt(c);return d.toString(36)}function p(a){var b=0;if(!a||!a.parentNode)return-1;for(;a&&(a=a.previousElementSibling);)"TEMPLATE"!==a.nodeName.toUpperCase()&&b++;return b}function q(a,b){var c,d;return function(){void 0===c&&(c=arguments,d=this,setTimeout(function(){1===c.length?a.call(d,c[0]):a.apply(d,c),c=void 0},b))}}function r(a,b){if(a&&b)for(var c in b)b.hasOwnProperty(c)&&(a[c]=b[c]);return a}var s,t,u,v,w,x,y,z,A,B,C,D,E,F,G,H,I,J={},K=/\s+/g,L="Sortable"+(new Date).getTime(),M=window,N=M.document,O=M.parseInt,P=!!("draggable"in N.createElement("div")),Q=function(a){return a=N.createElement("x"),a.style.cssText="pointer-events:auto","auto"===a.style.pointerEvents}(),R=!1,S=Math.abs,T=([].slice,[]),U=q(function(a,b,c){if(c&&b.scroll){var d,e,f,g,h=b.scrollSensitivity,i=b.scrollSpeed,j=a.clientX,k=a.clientY,l=window.innerWidth,m=window.innerHeight;if(z!==c&&(y=b.scroll,z=c,y===!0)){y=c;do if(y.offsetWidth<y.scrollWidth||y.offsetHeight<y.scrollHeight)break;while(y=y.parentNode)}y&&(d=y,e=y.getBoundingClientRect(),f=(S(e.right-j)<=h)-(S(e.left-j)<=h),g=(S(e.bottom-k)<=h)-(S(e.top-k)<=h)),f||g||(f=(h>=l-j)-(h>=j),g=(h>=m-k)-(h>=k),(f||g)&&(d=M)),(J.vx!==f||J.vy!==g||J.el!==d)&&(J.el=d,J.vx=f,J.vy=g,clearInterval(J.pid),d&&(J.pid=setInterval(function(){d===M?M.scrollTo(M.pageXOffset+f*i,M.pageYOffset+g*i):(g&&(d.scrollTop+=g*i),f&&(d.scrollLeft+=f*i))},24)))}},30),V=function(a){var b=a.group;b&&"object"==typeof b||(b=a.group={name:b}),["pull","put"].forEach(function(a){a in b||(b[a]=!0)}),a.groups=" "+b.name+(b.put.join?" "+b.put.join(" "):"")+" "};return a.prototype={constructor:a,_onTapStart:function(a){var b=this,d=this.el,e=this.options,f=a.type,g=a.touches&&a.touches[0],h=(g||a).target,i=h,k=e.filter;if(!("mousedown"===f&&0!==a.button||e.disabled)&&(h=c(h,e.draggable,d))){if(D=p(h),"function"==typeof k){if(k.call(this,a,h,this))return j(b,i,"filter",h,d,D),void a.preventDefault()}else if(k&&(k=k.split(",").some(function(a){return a=c(i,a.trim(),d),a?(j(b,a,"filter",h,d,D),!0):void 0})))return void a.preventDefault();(!e.handle||c(i,e.handle,d))&&this._prepareDragStart(a,g,h)}},_prepareDragStart:function(a,b,c){var d,f=this,h=f.el,j=f.options,k=h.ownerDocument;c&&!s&&c.parentNode===h&&(G=a,w=h,s=c,t=s.parentNode,x=s.nextSibling,F=j.group,d=function(){f._disableDelayedDrag(),s.draggable=!0,g(s,f.options.chosenClass,!0),f._triggerDragStart(b)},j.ignore.split(",").forEach(function(a){i(s,a.trim(),l)}),e(k,"mouseup",f._onDrop),e(k,"touchend",f._onDrop),e(k,"touchcancel",f._onDrop),j.delay?(e(k,"mouseup",f._disableDelayedDrag),e(k,"touchend",f._disableDelayedDrag),e(k,"touchcancel",f._disableDelayedDrag),e(k,"mousemove",f._disableDelayedDrag),e(k,"touchmove",f._disableDelayedDrag),f._dragStartTimer=setTimeout(d,j.delay)):d())},_disableDelayedDrag:function(){var a=this.el.ownerDocument;clearTimeout(this._dragStartTimer),f(a,"mouseup",this._disableDelayedDrag),f(a,"touchend",this._disableDelayedDrag),f(a,"touchcancel",this._disableDelayedDrag),f(a,"mousemove",this._disableDelayedDrag),f(a,"touchmove",this._disableDelayedDrag)},_triggerDragStart:function(a){a?(G={target:s,clientX:a.clientX,clientY:a.clientY},this._onDragStart(G,"touch")):this.nativeDraggable?(e(s,"dragend",this),e(w,"dragstart",this._onDragStart)):this._onDragStart(G,!0);try{N.selection?N.selection.empty():window.getSelection().removeAllRanges()}catch(b){}},_dragStarted:function(){w&&s&&(g(s,this.options.ghostClass,!0),a.active=this,j(this,w,"start",s,w,D))},_emulateDragOver:function(){if(H){if(this._lastX===H.clientX&&this._lastY===H.clientY)return;this._lastX=H.clientX,this._lastY=H.clientY,Q||h(u,"display","none");var a=N.elementFromPoint(H.clientX,H.clientY),b=a,c=" "+this.options.group.name,d=T.length;if(b)do{if(b[L]&&b[L].options.groups.indexOf(c)>-1){for(;d--;)T[d]({clientX:H.clientX,clientY:H.clientY,target:a,rootEl:b});break}a=b}while(b=b.parentNode);Q||h(u,"display","")}},_onTouchMove:function(b){if(G){a.active||this._dragStarted(),this._appendGhost();var c=b.touches?b.touches[0]:b,d=c.clientX-G.clientX,e=c.clientY-G.clientY,f=b.touches?"translate3d("+d+"px,"+e+"px,0)":"translate("+d+"px,"+e+"px)";I=!0,H=c,h(u,"webkitTransform",f),h(u,"mozTransform",f),h(u,"msTransform",f),h(u,"transform",f),b.preventDefault()}},_appendGhost:function(){if(!u){var a,b=s.getBoundingClientRect(),c=h(s),d=this.options;u=s.cloneNode(!0),g(u,d.ghostClass,!1),g(u,d.fallbackClass,!0),h(u,"top",b.top-O(c.marginTop,10)),h(u,"left",b.left-O(c.marginLeft,10)),h(u,"width",b.width),h(u,"height",b.height),h(u,"opacity","0.8"),h(u,"position","fixed"),h(u,"zIndex","100000"),h(u,"pointerEvents","none"),d.fallbackOnBody&&N.body.appendChild(u)||w.appendChild(u),a=u.getBoundingClientRect(),h(u,"width",2*b.width-a.width),h(u,"height",2*b.height-a.height)}},_onDragStart:function(a,b){var c=a.dataTransfer,d=this.options;this._offUpEvents(),"clone"==F.pull&&(v=s.cloneNode(!0),h(v,"display","none"),w.insertBefore(v,s)),b?("touch"===b?(e(N,"touchmove",this._onTouchMove),e(N,"touchend",this._onDrop),e(N,"touchcancel",this._onDrop)):(e(N,"mousemove",this._onTouchMove),e(N,"mouseup",this._onDrop)),this._loopId=setInterval(this._emulateDragOver,50)):(c&&(c.effectAllowed="move",d.setData&&d.setData.call(this,c,s)),e(N,"drop",this),setTimeout(this._dragStarted,0))},_onDragOver:function(a){var d,e,f,g=this.el,i=this.options,j=i.group,l=j.put,o=F===j,p=i.sort;if(void 0!==a.preventDefault&&(a.preventDefault(),!i.dragoverBubble&&a.stopPropagation()),I=!0,F&&!i.disabled&&(o?p||(f=!w.contains(s)):F.pull&&l&&(F.name===j.name||l.indexOf&&~l.indexOf(F.name)))&&(void 0===a.rootEl||a.rootEl===this.el)){if(U(a,i,this.el),R)return;if(d=c(a.target,i.draggable,g),e=s.getBoundingClientRect(),f)return b(!0),void(v||x?w.insertBefore(s,v||x):p||w.appendChild(s));if(0===g.children.length||g.children[0]===u||g===a.target&&(d=n(g,a))){if(d){if(d.animated)return;r=d.getBoundingClientRect()}b(o),k(w,g,s,e,d,r)!==!1&&(s.contains(g)||(g.appendChild(s),t=g),this._animate(e,s),d&&this._animate(r,d))}else if(d&&!d.animated&&d!==s&&void 0!==d.parentNode[L]){A!==d&&(A=d,B=h(d),C=h(d.parentNode));var q,r=d.getBoundingClientRect(),y=r.right-r.left,z=r.bottom-r.top,D=/left|right|inline/.test(B.cssFloat+B.display)||"flex"==C.display&&0===C["flex-direction"].indexOf("row"),E=d.offsetWidth>s.offsetWidth,G=d.offsetHeight>s.offsetHeight,H=(D?(a.clientX-r.left)/y:(a.clientY-r.top)/z)>.5,J=d.nextElementSibling,K=k(w,g,s,e,d,r);if(K!==!1){if(R=!0,setTimeout(m,30),b(o),1===K||-1===K)q=1===K;else if(D){var M=s.offsetTop,N=d.offsetTop;q=M===N?d.previousElementSibling===s&&!E||H&&E:N>M}else q=J!==s&&!G||H&&G;s.contains(g)||(q&&!J?g.appendChild(s):d.parentNode.insertBefore(s,q?J:d)),t=s.parentNode,this._animate(e,s),this._animate(r,d)}}}},_animate:function(a,b){var c=this.options.animation;if(c){var d=b.getBoundingClientRect();h(b,"transition","none"),h(b,"transform","translate3d("+(a.left-d.left)+"px,"+(a.top-d.top)+"px,0)"),b.offsetWidth,h(b,"transition","all "+c+"ms"),h(b,"transform","translate3d(0,0,0)"),clearTimeout(b.animated),b.animated=setTimeout(function(){h(b,"transition",""),h(b,"transform",""),b.animated=!1},c)}},_offUpEvents:function(){var a=this.el.ownerDocument;f(N,"touchmove",this._onTouchMove),f(a,"mouseup",this._onDrop),f(a,"touchend",this._onDrop),f(a,"touchcancel",this._onDrop)},_onDrop:function(b){var c=this.el,d=this.options;clearInterval(this._loopId),clearInterval(J.pid),clearTimeout(this._dragStartTimer),f(N,"mousemove",this._onTouchMove),this.nativeDraggable&&(f(N,"drop",this),f(c,"dragstart",this._onDragStart)),this._offUpEvents(),b&&(I&&(b.preventDefault(),!d.dropBubble&&b.stopPropagation()),u&&u.parentNode.removeChild(u),s&&(this.nativeDraggable&&f(s,"dragend",this),l(s),g(s,this.options.ghostClass,!1),g(s,this.options.chosenClass,!1),w!==t?(E=p(s),E>=0&&(j(null,t,"sort",s,w,D,E),j(this,w,"sort",s,w,D,E),j(null,t,"add",s,w,D,E),j(this,w,"remove",s,w,D,E))):(v&&v.parentNode.removeChild(v),s.nextSibling!==x&&(E=p(s),E>=0&&(j(this,w,"update",s,w,D,E),j(this,w,"sort",s,w,D,E)))),a.active&&((null===E||-1===E)&&(E=D),j(this,w,"end",s,w,D,E),this.save())),w=s=t=u=x=v=y=z=G=H=I=E=A=B=F=a.active=null)},handleEvent:function(a){var b=a.type;"dragover"===b||"dragenter"===b?s&&(this._onDragOver(a),d(a)):("drop"===b||"dragend"===b)&&this._onDrop(a)},toArray:function(){for(var a,b=[],d=this.el.children,e=0,f=d.length,g=this.options;f>e;e++)a=d[e],c(a,g.draggable,this.el)&&b.push(a.getAttribute(g.dataIdAttr)||o(a));return b},sort:function(a){var b={},d=this.el;this.toArray().forEach(function(a,e){var f=d.children[e];c(f,this.options.draggable,d)&&(b[a]=f)},this),a.forEach(function(a){b[a]&&(d.removeChild(b[a]),d.appendChild(b[a]))})},save:function(){var a=this.options.store;a&&a.set(this)},closest:function(a,b){return c(a,b||this.options.draggable,this.el)},option:function(a,b){var c=this.options;return void 0===b?c[a]:(c[a]=b,void("group"===a&&V(c)))},destroy:function(){var a=this.el;a[L]=null,f(a,"mousedown",this._onTapStart),f(a,"touchstart",this._onTapStart),this.nativeDraggable&&(f(a,"dragover",this),f(a,"dragenter",this)),Array.prototype.forEach.call(a.querySelectorAll("[draggable]"),function(a){a.removeAttribute("draggable")}),T.splice(T.indexOf(this._onDragOver),1),this._onDrop(),this.el=a=null}},a.utils={on:e,off:f,css:h,find:i,is:function(a,b){return!!c(a,b,a)},extend:r,throttle:q,closest:c,toggleClass:g,index:p},a.create=function(b,c){return new a(b,c)},a.version="1.4.2",a});
\ No newline at end of file
diff --git a/hubs/static/js/bootstrap3-typeahead.min.js b/hubs/static/js/bootstrap3-typeahead.min.js
deleted file mode 100644
index b8ffd9d..0000000
--- a/hubs/static/js/bootstrap3-typeahead.min.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/* =============================================================
- * bootstrap3-typeahead.js v3.1.0
- * https://github.com/bassjobsen/Bootstrap-3-Typeahead
- * =============================================================
- * Original written by @mdo and @fat
- * =============================================================
- * Copyright 2014 Bass Jobsen @bassjobsen
- *
- * Licensed under the Apache License, Version 2.0 (the 'License');
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an 'AS IS' BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- * ============================================================ */
-!function(a,b){"use strict";"undefined"!=typeof module&&module.exports?module.exports=b(require("jquery")):"function"==typeof define&&define.amd?define(["jquery"],function(a){return b(a)}):b(a.jQuery)}(this,function(a){"use strict";var b=function(b,c){this.$element=a(b),this.options=a.extend({},a.fn.typeahead.defaults,c),this.matcher=this.options.matcher||this.matcher,this.sorter=this.options.sorter||this.sorter,this.select=this.options.select||this.select,this.autoSelect="boolean"==typeof this.options.autoSelect?this.options.autoSelect:!0,this.highlighter=this.options.highlighter||this.highlighter,this.render=this.options.render||this.render,this.updater=this.options.updater||this.updater,this.displayText=this.options.displayText||this.displayText,this.source=this.options.source,this.delay=this.options.delay,this.$menu=a(this.options.menu),this.$appendTo=this.options.appendTo?a(this.options.appendTo):null,this.shown=!1,this.listen(),this.showHintOnFocus="boolean"==typeof this.options.showHintOnFocus?this.options.showHintOnFocus:!1,this.afterSelect=this.options.afterSelect,this.addItem=!1};b.prototype={constructor:b,select:function(){var a=this.$menu.find(".active").data("value");if(this.$element.data("active",a),this.autoSelect||a){var b=this.updater(a);b||(b=""),this.$element.val(this.displayText(b)||b).text(this.displayText(b)||b).change(),this.afterSelect(b)}return this.hide()},updater:function(a){return a},setSource:function(a){this.source=a},show:function(){var b,c=a.extend({},this.$element.position(),{height:this.$element[0].offsetHeight}),d="function"==typeof this.options.scrollHeight?this.options.scrollHeight.call():this.options.scrollHeight;b=this.shown?this.$menu:this.$appendTo?this.$menu.appendTo(this.$appendTo):this.$menu.insertAfter(this.$element);var e=a(b).parent().hasClass("dropup"),f=e?"auto":c.top+c.height+d,g=a(b).hasClass("dropdown-menu-right"),h=g?"auto":c.left;return b.css({top:f,left:h}).show(),this.shown=!0,this},hide:function(){return this.$menu.hide(),this.shown=!1,this},lookup:function(b){if("undefined"!=typeof b&&null!==b?this.query=b:this.query=this.$element.val()||this.$element.text()||"",this.query.length<this.options.minLength&&!this.options.showHintOnFocus)return this.shown?this.hide():this;var c=a.proxy(function(){a.isFunction(this.source)?this.source(this.query,a.proxy(this.process,this)):this.source&&this.process(this.source)},this);clearTimeout(this.lookupWorker),this.lookupWorker=setTimeout(c,this.delay)},process:function(b){var c=this;return b=a.grep(b,function(a){return c.matcher(a)}),b=this.sorter(b),b.length||this.options.addItem?(b.length>0?this.$element.data("active",b[0]):this.$element.data("active",null),this.options.addItem&&b.push(this.options.addItem),"all"==this.options.items?this.render(b).show():this.render(b.slice(0,this.options.items)).show()):this.shown?this.hide():this},matcher:function(a){var b=this.displayText(a);return~b.toLowerCase().indexOf(this.query.toLowerCase())},sorter:function(a){for(var b,c=[],d=[],e=[];b=a.shift();){var f=this.displayText(b);f.toLowerCase().indexOf(this.query.toLowerCase())?~f.indexOf(this.query)?d.push(b):e.push(b):c.push(b)}return c.concat(d,e)},highlighter:function(b){var c,d,e,f,g=a("<div></div>"),h=this.query,i=b.toLowerCase().indexOf(h.toLowerCase()),j=h.length;if(0===j)return g.text(b).html();for(;i>-1;)c=b.substr(0,i),d=b.substr(i,j),e=b.substr(i+j),f=a("<strong></strong>").text(d),g.append(document.createTextNode(c)).append(f),b=e,i=b.toLowerCase().indexOf(h.toLowerCase());return g.append(document.createTextNode(b)).html()},render:function(b){var c=this,d=this,e=!1,f=[],g=c.options.separator;return a.each(b,function(a,c){a>0&&c[g]!==b[a-1][g]&&f.push({__type:"divider"}),!c[g]||0!==a&&c[g]===b[a-1][g]||f.push({__type:"category",name:c[g]}),f.push(c)}),b=a(f).map(function(b,f){if("category"==(f.__type||!1))return a(c.options.headerHtml).text(f.name)[0];if("divider"==(f.__type||!1))return a(c.options.headerDivider)[0];var g=d.displayText(f);return b=a(c.options.item).data("value",f),b.find("a").html(c.highlighter(g,f)),g==d.$element.val()&&(b.addClass("active"),d.$element.data("active",f),e=!0),b[0]}),this.autoSelect&&!e&&(b.filter(":not(.dropdown-header)").first().addClass("active"),this.$element.data("active",b.first().data("value"))),this.$menu.html(b),this},displayText:function(a){return"undefined"!=typeof a&&"undefined"!=typeof a.name&&a.name||a},next:function(b){var c=this.$menu.find(".active").removeClass("active"),d=c.next();d.length||(d=a(this.$menu.find("li")[0])),d.addClass("active")},prev:function(a){var b=this.$menu.find(".active").removeClass("active"),c=b.prev();c.length||(c=this.$menu.find("li").last()),c.addClass("active")},listen:function(){this.$element.on("focus",a.proxy(this.focus,this)).on("blur",a.proxy(this.blur,this)).on("keypress",a.proxy(this.keypress,this)).on("input",a.proxy(this.input,this)).on("keyup",a.proxy(this.keyup,this)),this.eventSupported("keydown")&&this.$element.on("keydown",a.proxy(this.keydown,this)),this.$menu.on("click",a.proxy(this.click,this)).on("mouseenter","li",a.proxy(this.mouseenter,this)).on("mouseleave","li",a.proxy(this.mouseleave,this)).on("mousedown",a.proxy(this.mousedown,this))},mousedown:function(a){this.mouseddown=!0,a.stopPropagation(),a.preventDefault()},destroy:function(){this.$element.data("typeahead",null),this.$element.data("active",null),this.$element.off("focus").off("blur").off("keypress").off("input").off("keyup"),this.eventSupported("keydown")&&this.$element.off("keydown"),this.$menu.remove();this.destroyed=true;},eventSupported:function(a){var b=a in this.$element;return b||(this.$element.setAttribute(a,"return;"),b="function"==typeof this.$element[a]),b},move:function(a){if(this.shown)switch(a.keyCode){case 9:case 13:case 27:a.preventDefault();break;case 38:if(a.shiftKey)return;a.preventDefault(),this.prev();break;case 40:if(a.shiftKey)return;a.preventDefault(),this.next()}},keydown:function(b){this.suppressKeyPressRepeat=~a.inArray(b.keyCode,[40,38,9,13,27]),this.shown||40!=b.keyCode?this.move(b):this.lookup()},keypress:function(a){this.suppressKeyPressRepeat||this.move(a)},input:function(a){this.lookup(),a.preventDefault()},keyup:function(a){if(this.destroyed){return};switch(a.keyCode){case 40:case 38:case 16:case 17:case 18:break;case 9:case 13:if(!this.shown)return;this.select();break;case 27:if(!this.shown)return;this.hide()}a.preventDefault()},focus:function(a){this.focused||(this.focused=!0,this.options.showHintOnFocus&&this.lookup())},blur:function(a){this.focused=!1,!this.mousedover&&this.shown&&(this.mouseddown&&a.originalEvent?this.mouseddown=!1:this.hide())},click:function(a){a.preventDefault(),this.select(),this.$element.focus(),this.hide()},mouseenter:function(b){this.mousedover=!0,this.$menu.find(".active").removeClass("active"),a(b.currentTarget).addClass("active")},mouseleave:function(a){this.mousedover=!1}};var c=a.fn.typeahead;a.fn.typeahead=function(c){var d=arguments;return"string"==typeof c&&"getActive"==c?this.data("active"):this.each(function(){var e=a(this),f=e.data("typeahead"),g="object"==typeof c&&c;f||e.data("typeahead",f=new b(this,g)),"string"==typeof c&&f[c]&&(d.length>1?f[c].apply(f,Array.prototype.slice.call(d,1)):f[c]())})},a.fn.typeahead.defaults={source:[],items:8,menu:'<ul class="typeahead dropdown-menu" role="listbox"></ul>',item:'<li><a class="dropdown-item" href="#" role="option"></a></li>',minLength:1,scrollHeight:0,autoSelect:!0,afterSelect:a.noop,addItem:!1,delay:0,separator:"category",headerHtml:'<li class="dropdown-header"></li>',headerDivider:'<li class="divider" role="separator"></li>'},a.fn.typeahead.Constructor=b,a.fn.typeahead.noConflict=function(){return a.fn.typeahead=c,this},a(document).on("focus.typeahead.data-api",'[data-provide="typeahead"]',function(b){var c=a(this);c.data("typeahead")||c.typeahead(c.data())})});
diff --git a/hubs/static/js/hubs_input.js b/hubs/static/js/hubs_input.js
deleted file mode 100644
index 79b0fb0..0000000
--- a/hubs/static/js/hubs_input.js
+++ /dev/null
@@ -1,13 +0,0 @@
-$(function(){
-  if ($('#calendar') != null){
-    $.get(
-      'https://apps.fedoraproject.org/calendar/api/calendars/',
-      function(data){
-        data = JSON.parse(data);
-        var d = data.calendars.map(function(cal) { return cal.calendar_name; });
-        $('#calendar').typeahead({ source: d });
-      },
-      'jsonp'
-    );
-  }
-});
diff --git a/hubs/static/js/utils.js b/hubs/static/js/utils.js
deleted file mode 100644
index 63f3ed0..0000000
--- a/hubs/static/js/utils.js
+++ /dev/null
@@ -1,77 +0,0 @@
-// These two functions are used by the feed widget to expand and collapse
-// entries.
-var expand_feed_entries = function(idx) {
-    $("#expand-" + idx).addClass('hidden');
-    $("#collapse-" + idx).removeClass('hidden');
-    $("#content-" + idx).removeClass('hidden');
-}
-var collapse_feed_entries = function(idx) {
-    $("#expand-" + idx).removeClass('hidden');
-    $("#collapse-" + idx).addClass('hidden');
-    $("#content-" + idx).addClass('hidden');
-}
-
-// Load widgets
-function setup_widgets() {
-    $("div.widget[data-url]").each(function() {
-        var element=$(this);
-        $.ajax({
-            url: element.attr("data-url"),
-            dataType: 'html',
-            success: function(html) {
-                element.html(html);
-                setTimeout(function() {
-                  element.find('.panel').toggleClass('panel-visible');
-                }, 100);
-            },
-            error: function() {
-                element.html('Got an error retrieving this widget.  Sorry :(');
-                console.log('error');
-                console.trace();
-            },
-        });
-    });
-}
-
-function setup_sse(url) {
-    if (!url || !window.EventSource) {
-      // Auto-update is disabled.
-      return;
-    }
-    var sseSource = new Hubs.ReconnectingEventSource(url),
-        notifTimeout = null;
-    sseSource.addEventListener("hubs:widget-updated", function(e) {
-        var widget = $("#widget-" + e.data);
-        if (widget.length === 0) { return; }
-        if (widget.find("div.card[data-disable-autoreload]").length !== 0) { return; }
-        $.ajax({
-            url: widget.attr("data-url"),
-            dataType: 'html',
-            success: function(html) {
-                widget.css("opacity", "0.5");
-                widget.html(html);
-                widget.animate({opacity: 1}, 200);
-            },
-            error: function() {
-                console.log('error updating the widget');
-                console.trace();
-            },
-        });
-    });
-    function change_notify_icon(color) {
-        // Set the notify icon for 5s
-        if (color == "green") {
-            color = "#33ff00";
-        } else if (color == "red") {
-            color = "#eb361e";
-        }
-        Notificon(color);
-        if (notifTimeout) { clearTimeout(notifTimeout); }
-        notifTimeout = setTimeout(function(){ Notificon(); }, 5000)
-    }
-    // Icon to red on disconnect, icon to green on reconnect.
-    sseSource.addEventListener("error", function() { change_notify_icon("red"); });
-    sseSource.addEventListener("open", function() { change_notify_icon("green"); });
-    // Make it accessible by other components on the page.
-    document.sseSource = sseSource;
-  }
diff --git a/hubs/templates/master.html b/hubs/templates/master.html
index 1a74f82..3e65d31 100644
--- a/hubs/templates/master.html
+++ b/hubs/templates/master.html
@@ -165,7 +165,6 @@
 <script src="{{ url_for('static', filename='fedora-bootstrap/fedora-bootstrap.min.js') }}"></script>
 <script src="https://unpkg.com/react@15/dist/react{% if not config.DEBUG %}.min{% endif %}.js"></script>
 <script src="https://unpkg.com/react-dom@15/dist/react-dom{% if not config.DEBUG %}.min{% endif %}.js"></script>
-<script src="{{ url_for('static', filename='js/utils.js') }}"></script>
 <script src="{{ url_for('static', filename='js/build/common.js') }}"></script>
 <script src="{{ url_for('static', filename='js/build/Hubs.js') }}"></script>
 {% endblock %}

From 76b462461bf63f4cfdfae695792ebb962d6d5d61 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Oct 02 2017 15:27:34 +0000
Subject: [PATCH 17/20] Add the new and reworked React UI


---

diff --git a/hubs/static/client/.babelrc b/hubs/static/client/.babelrc
index 25043d0..aa30d85 100644
--- a/hubs/static/client/.babelrc
+++ b/hubs/static/client/.babelrc
@@ -1,5 +1,10 @@
 {
-  "presets" : ["es2015", "react"],
-  "plugins": ["syntax-dynamic-import"]
+  "presets" : [["es2015", {"modules": false}], "react"],
+  "plugins": ["syntax-dynamic-import", "transform-object-rest-spread"],
+  "env": {
+    "test": {
+      "plugins": ["transform-es2015-modules-commonjs", "dynamic-import-node", "transform-object-rest-spread"]
+    }
+  }
 }
 
diff --git a/hubs/static/client/.eslintrc.json b/hubs/static/client/.eslintrc.json
index 86fbafe..2fe5df5 100644
--- a/hubs/static/client/.eslintrc.json
+++ b/hubs/static/client/.eslintrc.json
@@ -1,12 +1,20 @@
 {
   "parser": "babel-eslint",
+  "env": {
+    "browser": true,
+    "node": true,
+    "jquery": true
+  },
   "plugins": [
     "react"
   ],
   "rules": {
-    "arrow-body-style": "off",
-    "func-names": "off",
-    "react/prefer-stateless-function": "off",
     "react/prop-types": "off"
-  }
+  },
+  "parserOptions": {
+    "ecmaFeatures": {
+      "jsx": true
+    }
+  },
+  "extends": ["plugin:react/recommended"]
 }
diff --git a/hubs/static/client/__mocks__/fileMock.js b/hubs/static/client/__mocks__/fileMock.js
new file mode 100644
index 0000000..7275f13
--- /dev/null
+++ b/hubs/static/client/__mocks__/fileMock.js
@@ -0,0 +1,2 @@
+// http://facebook.github.io/jest/docs/en/webpack.html
+module.exports = 'test-file-stub';
diff --git a/hubs/static/client/__mocks__/styleMock.js b/hubs/static/client/__mocks__/styleMock.js
new file mode 100644
index 0000000..6a99daa
--- /dev/null
+++ b/hubs/static/client/__mocks__/styleMock.js
@@ -0,0 +1,2 @@
+// http://facebook.github.io/jest/docs/en/webpack.html
+module.exports = {};
diff --git a/hubs/static/client/app/components/AddWidget/AddWidgetDialog.js b/hubs/static/client/app/components/AddWidget/AddWidgetDialog.js
new file mode 100644
index 0000000..2d48f90
--- /dev/null
+++ b/hubs/static/client/app/components/AddWidget/AddWidgetDialog.js
@@ -0,0 +1,198 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import SimpleWidgetConfig from '../SimpleWidgetConfig';
+import WidgetSelectDialog from './WidgetSelectDialog';
+import Modal from '../Modal';
+import { getAvailableWidgets } from '../../core/actions/widgets';
+import { addWidget } from '../../core/actions/widget';
+import {
+  makeLoadable,
+  makeLoadingComponent
+} from '../../core/utils';
+
+
+class AddWidgetDialog extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      selectedWidget: null,
+      tempSelectedWidget: null, // only track the select dropdown state
+      widgetConfig: {visibility: "public"},
+    };
+    this.handleSelectClicked = this.handleSelectClicked.bind(this);
+    this.handleAddClicked = this.handleAddClicked.bind(this);
+    this.handleWidgetSelectChanged = this.handleWidgetSelectChanged.bind(this);
+    this.setWidgetConfig = this.setWidgetConfig.bind(this);
+  }
+
+  componentDidMount() {
+    this.props.dispatch(getAvailableWidgets());
+  }
+
+  componentDidUpdate(prevProps, prevState) {
+    // Select the first available widget by default.
+    const widgets = this.props.availableWidgets[this.props.position];
+    if (widgets.length > 0 && !this.state.tempSelectedWidget) {
+      this.setState({tempSelectedWidget: widgets[0]});
+    }
+  }
+
+  handleWidgetSelectChanged(e) {
+    this.setState({tempSelectedWidget: e.target.value});
+  }
+
+  setWidgetConfig(name, value) {
+    this.setState((prevState, props) => {
+      prevState.widgetConfig[name] = value;
+      return {widgetConfig: prevState.widgetConfig};
+    });
+  }
+
+  handleSelectClicked(e) {
+    e.preventDefault();
+    this.setState((prevState, props) => {
+      let selectedName = prevState.tempSelectedWidget,
+          selectedWidget = this.props.availableWidgets.byName[selectedName],
+          widgetConfig = {};
+      selectedWidget.params.forEach((param) => {
+        widgetConfig[param.name] = param.default;
+      });
+      return { selectedWidget, widgetConfig };
+    });
+  }
+
+  handleAddClicked(e) {
+    e.preventDefault();
+    this.props.dispatch(addWidget(
+      this.state.selectedWidget.name,
+      this.state.widgetConfig,
+      this.props.position)
+    );
+    this.props.handleDialogCloseClicked(e);
+  }
+
+  render() {
+    const widgets = this.props.availableWidgets[this.props.position].map(
+      (name) => (this.props.availableWidgets.byName[name])
+    );
+    let title = "Add a new widget",
+        contents, submitButton;
+
+    if (this.props.isLoading) {
+      contents = (
+        <p className="text-muted">Loading...</p>
+        );
+      submitButton = (
+        <button
+          type="button"
+          className="btn btn-primary"
+          disabled={true}
+          >
+          Loading...
+        </button>
+      );
+
+    } else if (this.state.selectedWidget === null) {
+      // First step: select the widget
+      contents = (
+        <WidgetSelectDialog
+          availableWidgets={widgets}
+          handleWidgetSelectChanged={this.handleWidgetSelectChanged}
+          selectedWidgetName={this.state.tempSelectedWidget}
+          selectedVisibility={this.state.widgetConfig.visibility}
+          setConfig={this.setWidgetConfig}
+          showVisibilitySelect={this.props.showVisibilitySelect}
+          />
+      );
+      submitButton = (
+        <button
+          type="button"
+          className="btn btn-primary ml-3"
+          onClick={this.handleSelectClicked}
+          >
+          Select widget
+        </button>
+      );
+
+    } else {
+      // Second step: configure the widget
+      title = "Adding widget " + this.state.selectedWidget.label;
+      if (this.state.selectedWidget.isReact) {
+        const AsyncComponent = makeLoadable(
+          () => import(`../../widgets/${this.state.selectedWidget.name}/Config`),
+          makeLoadingComponent(
+            "Loading widget configuration...",
+            "Sorry, there was a problem loading the widget configuration."
+          )
+        );
+        contents = (
+          <AsyncComponent
+            widget={this.state.selectedWidget}
+            values={this.state.widgetConfig}
+            setConfig={this.setWidgetConfig}
+            />
+        );
+      } else {
+        contents = (
+          <SimpleWidgetConfig
+            widget={this.state.selectedWidget}
+            values={this.state.widgetConfig}
+            setConfig={this.setWidgetConfig}
+            />
+        );
+      }
+      submitButton = (
+        <button
+          type="button"
+          className="btn btn-primary ml-3"
+          onClick={this.handleAddClicked}
+          >
+          Add widget
+        </button>
+      );
+    }
+
+    const footer = (
+      <div className="d-flex justify-content-end w-100">
+        <button
+          type="button"
+          className="btn btn-secondary"
+          onClick={this.props.handleDialogCloseClicked}
+          >
+          Close
+        </button>
+        {submitButton}
+      </div>
+    );
+
+    return (
+      <Modal
+        onCloseClicked={this.props.handleDialogCloseClicked}
+        isLarge={this.state.selectedWidget && this.state.selectedWidget.isReact}
+        title={title}
+        footer={footer}
+        >
+        {contents}
+      </Modal>
+    );
+  }
+}
+AddWidgetDialog.propTypes = {
+  position: PropTypes.string.isRequired,
+  handleDialogCloseClicked: PropTypes.func,
+}
+
+
+const mapStateToProps = (state) => {
+  return {
+    availableWidgets: state.entities.availableWidgets,
+    isLoading: state.entities.availableWidgets.isLoading,
+    showVisibilitySelect: (
+      state.entities.hub.config.visibility === "preview"
+    ),
+  }
+};
+
+export default connect(mapStateToProps)(AddWidgetDialog);
diff --git a/hubs/static/client/app/components/AddWidget/WidgetSelectDialog.js b/hubs/static/client/app/components/AddWidget/WidgetSelectDialog.js
new file mode 100644
index 0000000..9577e71
--- /dev/null
+++ b/hubs/static/client/app/components/AddWidget/WidgetSelectDialog.js
@@ -0,0 +1,53 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import VisibilitySelect from '../VisibilitySelect';
+
+
+export default class WidgetSelectDialog extends React.Component {
+
+  propTypes: {
+    availableWidgets: PropTypes.array.isRequired,
+    showVisibilitySelect: PropTypes.bool,
+    handleWidgetSelectChanged: PropTypes.func.isRequired,
+    selectedWidgetName: PropTypes.string.isRequired,
+    selectedVisibility: PropTypes.string.isRequired,
+    setConfig: PropTypes.func.isRequired,
+  }
+
+  render() {
+    const options = this.props.availableWidgets.map((widget) => {
+      return (
+        <option
+          key={widget.name}
+          value={widget.name}
+          >
+          {widget.label}
+        </option>
+      );
+    });
+    return (
+      <div>
+        <fieldset className="form-group">
+          { this.props.showVisibilitySelect &&
+            <label htmlFor="widgetname">Widget name:</label>
+          }
+          <select
+            className="form-control"
+            name="widgetname"
+            onChange={this.props.handleWidgetSelectChanged}
+            value={this.props.selectedWidgetName || ""}
+            >
+            {options}
+          </select>
+        </fieldset>
+        { this.props.showVisibilitySelect &&
+          <VisibilitySelect
+            selectedVisibility={this.props.selectedVisibility}
+            setConfig={this.props.setConfig}
+            />
+        }
+      </div>
+    );
+  }
+}
diff --git a/hubs/static/client/app/components/AddWidget/index.js b/hubs/static/client/app/components/AddWidget/index.js
new file mode 100644
index 0000000..18c3455
--- /dev/null
+++ b/hubs/static/client/app/components/AddWidget/index.js
@@ -0,0 +1,75 @@
+import React from 'react';
+import {
+  defineMessages,
+  FormattedMessage,
+  } from 'react-intl';
+import PropTypes from 'prop-types';
+import AddWidgetDialog from './AddWidgetDialog';
+
+
+const messages = defineMessages({
+  button: {
+    id: "hubs.core.widgets.add.button",
+    defaultMessage: "Add a widget",
+  },
+});
+
+
+class AddWidgetButton extends React.Component {
+
+  render() {
+    return (
+      <div className='widget row AddWidgetButton'>
+        <button
+          className="btn btn-lg btn-secondary w-100"
+          onClick={this.props.handleClicked}
+          >
+          <i className="fa fa-plus mr-2" aria-hidden="true"></i>
+          <FormattedMessage {...messages.button} />
+        </button>
+      </div>
+    );
+  }
+}
+
+
+export default class AddWidget extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      isDialogOpen: false,
+    };
+    this.handleOpenClicked = this.handleOpenClicked.bind(this);
+    this.handleCloseClicked = this.handleCloseClicked.bind(this);
+  }
+
+  handleOpenClicked(e) {
+    e.preventDefault();
+    this.setState({
+      isDialogOpen: true,
+    });
+  }
+
+  handleCloseClicked(e) {
+    if (e) { e.preventDefault(); }
+    this.setState({isDialogOpen: false});
+  }
+
+  render() {
+    return (
+      <div>
+        <AddWidgetButton
+          handleClicked={this.handleOpenClicked}
+          />
+        { this.state.isDialogOpen &&
+          <AddWidgetDialog
+            position={this.props.position}
+            handleDialogCloseClicked={this.handleCloseClicked}
+            />
+        }
+      </div>
+    );
+  }
+
+}
diff --git a/hubs/static/client/app/components/CobWeb/archived.png b/hubs/static/client/app/components/CobWeb/archived.png
new file mode 100644
index 0000000..0084328
Binary files /dev/null and b/hubs/static/client/app/components/CobWeb/archived.png differ
diff --git a/hubs/static/client/app/components/CobWeb/cobweb.png b/hubs/static/client/app/components/CobWeb/cobweb.png
new file mode 100644
index 0000000..4d94b12
Binary files /dev/null and b/hubs/static/client/app/components/CobWeb/cobweb.png differ
diff --git a/hubs/static/client/app/components/CobWeb/index.js b/hubs/static/client/app/components/CobWeb/index.js
new file mode 100644
index 0000000..65b807a
--- /dev/null
+++ b/hubs/static/client/app/components/CobWeb/index.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import TimeAgo from 'react-timeago';
+//import './cobweb.css';
+import ArchivedIcon from './archived.png';
+import CobWebIcon from './cobweb.png';
+
+
+export default class CobWeb extends React.Component {
+
+  render() {
+    let icon = null, msg = null;
+    if (this.props.cause == "archived") {
+        icon = ArchivedIcon;
+        msg = "This hub has been archived and locked.";
+    } else {
+        icon = CobWebIcon;
+        msg = (
+            <span>
+                <span>Cobweb alert!  This hub was last active </span>
+                <TimeAgo date={this.props.mtime} />
+            </span>
+            );
+    }
+    return (
+      <div className="CobWeb media mb-3">
+        <img className="d-flex align-self-center mr-3 square-32" src={icon} />
+        <div className="media-body">
+          {msg}
+        </div>
+      </div>
+    );
+  }
+}
+
diff --git a/hubs/static/client/app/components/CompletionInput.css b/hubs/static/client/app/components/CompletionInput.css
new file mode 100644
index 0000000..1628fe3
--- /dev/null
+++ b/hubs/static/client/app/components/CompletionInput.css
@@ -0,0 +1,36 @@
+.CompletionInput {
+  position: relative;
+}
+
+.CompletionInput .react-autosuggest__suggestions-container {
+  position: absolute;
+  width: 100%;
+  z-index: 10;
+}
+
+.CompletionInput .react-autosuggest__suggestions-list {
+  list-style: none;
+  padding: 0.3em 0.8em;
+  background-color: #fafafa;
+  border-bottom-left-radius: .25rem;
+  border-bottom-right-radius: .25rem;
+  border: 1px solid #ccc;
+  border-top: none;
+  text-align: left;
+}
+
+.CompletionInput .react-autosuggest__suggestion {
+  cursor: pointer;
+}
+
+.CompletionInput .react-autosuggest__container--open input {
+  border-bottom-left-radius: 0px;
+  border-bottom-right-radius: 0px;
+}
+
+.CompletionInput .SpinnerCircle {
+  position: absolute;
+  top: 0;
+  right: 0;
+  z-index: 2;
+}
diff --git a/hubs/static/client/app/components/CompletionInput.js b/hubs/static/client/app/components/CompletionInput.js
new file mode 100644
index 0000000..a90cd07
--- /dev/null
+++ b/hubs/static/client/app/components/CompletionInput.js
@@ -0,0 +1,144 @@
+import React from 'react';
+import Autosuggest from 'react-autosuggest';
+import Spinner from "./Spinner";
+import "./CompletionInput.css";
+
+
+export default class CompletionInput extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      value: "",
+      suggestions: [],
+      isLoading: false
+    }
+    this.resultsDomNodeStyle = {
+      position: "absolute",
+      width: "auto"
+    }
+    this.queryTimeout = null;
+    this.handleChange = this.handleChange.bind(this);
+    this.handleSuggestionSelected = this.handleSuggestionSelected.bind(this);
+    this.handleSuggestionsFetchRequested = this.handleSuggestionsFetchRequested.bind(this);
+    this.handleSuggestionsClearRequested = this.handleSuggestionsClearRequested.bind(this);
+    this.loadFromServer = this.loadFromServer.bind(this);
+  }
+
+  componentWillUnmount() {
+    if (this.serverRequest) { this.serverRequest.abort(); }
+  }
+
+  handleChange(e, {newValue}) {
+    this.setState({
+      value: newValue
+    });
+    // Transmit a "fake" changed event to the parent, if asked to.
+    if (this.props.onChange) {
+      const mockedEvent = {
+        type: 'change',
+        target: {
+          type: 'input',
+          name: this.props.name || "completioninput",
+          value: newValue,
+        },
+      }
+      this.props.onChange(mockedEvent, newValue);
+    }
+  }
+
+  handleSuggestionSelected(e, {suggestion}) {
+    if (this.props.onSelect) {
+      this.props.onSelect(suggestion);
+    }
+  }
+
+  // Autosuggest will call this function every time you need to update
+  // suggestions.
+  handleSuggestionsFetchRequested({value}) {
+    var inputValue = value.trim().toLowerCase();
+    if (this.queryTimeout !== null) {
+      window.clearTimeout(this.queryTimeout);
+    }
+    if (this.serverRequest) { this.serverRequest.abort(); }
+    if (inputValue.length === 0) {
+      this.setState({
+        suggestions: [],
+        isLoading: false
+      });
+    } else {
+      this.queryTimeout = window.setTimeout(
+        this.loadFromServer, this.props.queryDelay, inputValue);
+    }
+  }
+
+  loadFromServer(value) {
+    this.setState({isLoading: true});
+    this.serverRequest = $.ajax({
+      url: this.props.url,
+      method: 'GET',
+      data: {q: value},
+      dataType: 'json',
+      cache: false,
+      success: function(result) {
+        var suggestions = result.data;
+        if (this.props.filterResults) {
+          suggestions = suggestions.filter(this.props.filterResults);
+        }
+        this.setState({suggestions: suggestions});
+      }.bind(this),
+      error: function(xhr, status, err) {
+        console.error(this.props.url, status, err.toString());
+      }.bind(this),
+      complete: function(xhr, status) {
+        this.setState({isLoading: false});
+      }.bind(this)
+    });
+  }
+
+  // Autosuggest will call this function every time you need to clear
+  // suggestions.
+  handleSuggestionsClearRequested() {
+    this.setState({
+      suggestions: []
+    });
+  }
+
+  render() {
+    // When a suggestion is clicked, this method will return the value to
+    // populate the input with.
+    const getSuggestionValue = (suggestion) => (suggestion);
+    // Render suggestions.
+    const renderSuggestion = (suggestion) => (suggestion);
+    // Autosuggest will pass through all these props to the input element.
+    const inputProps = {
+      className: "form-control",
+      value: this.state.value,
+      onChange: this.handleChange,
+    };
+    Object.assign(inputProps, this.props.inputProps);
+
+    // Render the widget.
+    return (
+      <div className="CompletionInput">
+        <Autosuggest
+          suggestions={this.state.suggestions}
+          onSuggestionsFetchRequested={this.handleSuggestionsFetchRequested}
+          onSuggestionsClearRequested={this.handleSuggestionsClearRequested}
+          onSuggestionSelected={this.handleSuggestionSelected}
+          getSuggestionValue={this.props.getSuggestionValue || getSuggestionValue}
+          renderSuggestion={this.props.renderSuggestion || renderSuggestion}
+          inputProps={inputProps}
+        />
+        {this.state.isLoading &&
+          <Spinner circle={true} />
+        }
+      </div>
+    );
+  }
+
+}
+CompletionInput.defaultProps = {
+    inputProps: {},
+    queryDelay: 800,
+};
diff --git a/hubs/static/client/app/components/CompletionInput.jsx b/hubs/static/client/app/components/CompletionInput.jsx
deleted file mode 100644
index a97fc7f..0000000
--- a/hubs/static/client/app/components/CompletionInput.jsx
+++ /dev/null
@@ -1,146 +0,0 @@
-import React from 'react';
-import Autosuggest from 'react-autosuggest';
-
-
-export default class CompletionInput extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.state = {
-      value: "",
-      suggestions: [],
-      loading: false
-    }
-    this.resultsDomNodeStyle = {
-      position: "absolute",
-      width: "auto"
-    }
-    this.queryTimeout = null;
-    this.onResultClick = this.onResultClick.bind(this);
-    this.onChange = this.onChange.bind(this);
-    this.onSuggestionsFetchRequested = this.onSuggestionsFetchRequested.bind(this);
-    this.onSuggestionsClearRequested = this.onSuggestionsClearRequested.bind(this);
-    this.loadFromServer = this.loadFromServer.bind(this);
-  }
-
-  componentWillUnmount() {
-    if (this.serverRequest) { this.serverRequest.abort(); }
-  }
-
-  onResultClick(result) {
-    this.setState({
-      results: [],
-      value: result
-    });
-  }
-
-  onChange(e, {newValue}) {
-    this.setState({
-      value: newValue
-    });
-    // Transmit a "fake" changed event to the parent, if asked to.
-    if (this.props.onChange) {
-      const mockedEvent = {
-        type: 'change',
-        target: {
-          type: 'input',
-          name: this.props.name,
-          value: newValue,
-        },
-      }
-      this.props.onChange(mockedEvent);
-    }
-  }
-
-  // Autosuggest will call this function every time you need to update
-  // suggestions.
-  onSuggestionsFetchRequested({value}) {
-    var inputValue = value.trim().toLowerCase();
-    if (this.queryTimeout !== null) {
-      window.clearTimeout(this.queryTimeout);
-    }
-    if (this.serverRequest) { this.serverRequest.abort(); }
-    if (inputValue.length === 0) {
-      this.setState({
-        suggestions: [],
-        loading: false
-      });
-    } else {
-      this.queryTimeout = window.setTimeout(
-        this.loadFromServer, this.props.queryDelay, inputValue);
-    }
-  }
-
-  loadFromServer(value) {
-    this.setState({loading: true});
-    this.serverRequest = $.ajax({
-      url: this.props.url,
-      method: 'GET',
-      data: {q: value},
-      dataType: 'json',
-      cache: false,
-      success: function(data) {
-        var suggestions = data.results;
-        if (this.props.filterResults) {
-          suggestions = suggestions.filter(this.props.filterResults);
-        }
-        this.setState({suggestions: suggestions});
-      }.bind(this),
-      error: function(xhr, status, err) {
-        console.error(this.props.url, status, err.toString());
-      }.bind(this),
-      complete: function(xhr, status) {
-        this.setState({loading: false});
-      }.bind(this)
-    });
-  }
-
-  // Autosuggest will call this function every time you need to clear
-  // suggestions.
-  onSuggestionsClearRequested() {
-    this.setState({
-      suggestions: []
-    });
-  }
-
-  render() {
-    // When a suggestion is clicked, this method will return the value to
-    // populate the input with.
-    const getSuggestionValue = suggestion => suggestion;
-    // Render suggestions.
-    const renderSuggestion = suggestion => suggestion;
-    // Autosuggest will pass through all these props to the input element.
-    const inputProps = {
-      className: "form-control",
-      value: this.state.value,
-      onChange: this.onChange,
-    };
-    Object.assign(inputProps, this.props.inputProps);
-
-    // Render the widget.
-    return (
-      <div className="completion-input">
-        <Autosuggest
-          suggestions={this.state.suggestions}
-          onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
-          onSuggestionsClearRequested={this.onSuggestionsClearRequested}
-          getSuggestionValue={getSuggestionValue}
-          renderSuggestion={renderSuggestion}
-          inputProps={inputProps}
-        />
-        <div
-          className="loading-circle"
-          style={this.state.loading ? {display: "block"} : {}}
-          ></div>
-      </div>
-    );
-  }
-
-}
-CompletionInput.defaultProps = {
-    inputProps: {},
-    queryDelay: 800,
-};
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/components/EditModeButton.css b/hubs/static/client/app/components/EditModeButton.css
new file mode 100644
index 0000000..69930aa
--- /dev/null
+++ b/hubs/static/client/app/components/EditModeButton.css
@@ -0,0 +1,3 @@
+.EditModeButton {
+    margin: 0.25rem 0.5rem
+}
diff --git a/hubs/static/client/app/components/EditModeButton.js b/hubs/static/client/app/components/EditModeButton.js
new file mode 100644
index 0000000..70eb64d
--- /dev/null
+++ b/hubs/static/client/app/components/EditModeButton.js
@@ -0,0 +1,74 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import {
+  defineMessages,
+  FormattedMessage,
+  } from 'react-intl';
+import { setEditMode } from "../core/actions/widgets";
+import "./EditModeButton.css";
+
+
+const messages = defineMessages({
+  save: {
+    id: "hubs.core.EditModeButton.save",
+    defaultMessage: "Save changes",
+  },
+  edit: {
+    id: "hubs.core.EditModeButton.edit",
+    defaultMessage: "Edit page layout",
+  },
+});
+
+
+class EditModeButton extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.handleClicked = this.handleClicked.bind(this);
+  }
+
+  handleClicked(e) {
+    e.preventDefault();
+    this.props.dispatch(setEditMode(!this.props.editMode));
+  }
+
+  render() {
+    if (this.props.editMode) {
+      return (
+        <button
+          className="EditModeButton btn btn-sm btn-primary"
+          onClick={this.handleClicked}
+          >
+          <i className="fa fa-download mr-2" aria-hidden="true"></i>
+          <FormattedMessage {...messages.save} />
+        </button>
+      );
+    } else {
+      return (
+        <button
+          className="EditModeButton btn btn-sm btn-secondary"
+          onClick={this.handleClicked}
+          >
+          <i className="fa fa-pencil mr-2" aria-hidden="true"></i>
+          <FormattedMessage {...messages.edit} />
+        </button>
+      );
+    }
+  }
+}
+EditModeButton.propTypes = {
+  editMode: PropTypes.bool,
+}
+EditModeButton.defaultProps = {
+  editMode: false
+}
+
+
+const mapStateToProps = (state) => {
+  return {
+    editMode: state.ui.widgetsEditMode
+  }
+};
+
+export default connect(mapStateToProps)(EditModeButton);
diff --git a/hubs/static/client/app/components/FlashMessages/FlashMessage.js b/hubs/static/client/app/components/FlashMessages/FlashMessage.js
new file mode 100644
index 0000000..02b903d
--- /dev/null
+++ b/hubs/static/client/app/components/FlashMessages/FlashMessage.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+
+const MSG_TYPES = ["success", "info", "warning", "danger"];
+
+
+export default class FlashMessage extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.handleClose = this.handleClose.bind(this);
+  }
+
+  handleClose(e) {
+    this.props.onClose(this.props.index);
+  }
+
+  render() {
+    let cssClass = this.props.type;
+    if (cssClass === "error") {
+      cssClass = "danger";
+    }
+    if (!MSG_TYPES.includes(cssClass)) {
+      cssClass = "info";
+    }
+    return (
+      <div
+        className={`alert alert-dismissible alert-${cssClass}`}
+        role="alert"
+        >
+        <button
+          type="button"
+          className="close"
+          aria-label="Close"
+          name={this.props.index}
+          onClick={this.handleClose}
+          >
+          <span aria-hidden="true">&times;</span>
+        </button>
+        {this.props.message}
+      </div>
+    );
+  }
+}
+FlashMessage.propTypes = {
+  message: PropTypes.string.isRequired,
+  index: PropTypes.number.isRequired,
+  type: PropTypes.string,
+  onClose: PropTypes.func,
+}
+FlashMessage.defaultProps = {
+  type: "info",
+}
diff --git a/hubs/static/client/app/components/FlashMessages/FlashMessages.css b/hubs/static/client/app/components/FlashMessages/FlashMessages.css
new file mode 100644
index 0000000..566132f
--- /dev/null
+++ b/hubs/static/client/app/components/FlashMessages/FlashMessages.css
@@ -0,0 +1,6 @@
+.FlashMessages {
+  /* place it on top so they cover content and can be clicked */
+  position: absolute;
+  width: 100%;
+  z-index: 3;
+}
diff --git a/hubs/static/client/app/components/FlashMessages/FlashMessagesList.js b/hubs/static/client/app/components/FlashMessages/FlashMessagesList.js
new file mode 100644
index 0000000..851da6a
--- /dev/null
+++ b/hubs/static/client/app/components/FlashMessages/FlashMessagesList.js
@@ -0,0 +1,39 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import FlashMessage from './FlashMessage';
+import "./FlashMessages.css";
+
+
+export default class FlashMessagesList extends React.Component {
+
+  render() {
+    if (this.props.messages.length === 0) {
+      return null;
+    }
+    let messages = this.props.messages.map(({msg, type}, index) => {
+      return (
+        <FlashMessage
+          key={index}
+          message={msg}
+          type={type}
+          index={index}
+          onClose={this.props.onClose}
+          />
+      );
+    });
+    return (
+      <div className="FlashMessages row pt-2">
+        <div className="col-md-6 offset-md-3">
+          {messages}
+        </div>
+      </div>
+    );
+  }
+}
+FlashMessagesList.propTypes = {
+  messages: PropTypes.array,
+  onClose: PropTypes.func,
+}
+FlashMessagesList.defaultProps = {
+  messages: [],
+}
diff --git a/hubs/static/client/app/components/FlashMessages/index.js b/hubs/static/client/app/components/FlashMessages/index.js
new file mode 100644
index 0000000..278af61
--- /dev/null
+++ b/hubs/static/client/app/components/FlashMessages/index.js
@@ -0,0 +1,23 @@
+import { connect } from 'react-redux';
+import { closeFlashMessage } from '../../core/actions/flashMessages';
+import FlashMessagesList from "./FlashMessagesList";
+
+
+const mapStateToProps = (state) => {
+  return {
+    messages: state.flashMessages
+  }
+};
+
+const mapDispatchToProps = (dispatch) => {
+  return {
+    onClose: (id) => {
+      dispatch(closeFlashMessage(id))
+    }
+  }
+};
+
+export default connect(
+  mapStateToProps,
+  mapDispatchToProps
+)(FlashMessagesList);
diff --git a/hubs/static/client/app/components/HubConfig/HubConfig.css b/hubs/static/client/app/components/HubConfig/HubConfig.css
new file mode 100644
index 0000000..d676968
--- /dev/null
+++ b/hubs/static/client/app/components/HubConfig/HubConfig.css
@@ -0,0 +1,46 @@
+.HubConfig {
+    display: inline;
+    margin: 0.25rem 0.5rem;
+    text-align: left;
+}
+.HubConfigDialog .modal-title {
+  line-height: 1;
+}
+
+.HubConfigDialog .nav {
+  border: 0;
+  background-color: #eee;
+}
+
+.HubConfigDialog .nav-tabs .nav-item {
+  float: none;
+  margin-bottom: 0;
+}
+.HubConfigDialog .nav-tabs .nav-link {
+  border: 0;
+}
+
+.HubConfigDialog .modal-body {
+  padding: 0;
+}
+.HubConfigDialog .modal-body h3 {
+  margin-bottom: 1em;
+}
+.HubConfigDialog .modal-body label {
+  font-size: 115%;
+}
+.HubConfigDialog .modal-body .tab-content {
+  padding: 1em;
+  padding-bottom: 2em;
+}
+
+@media (min-width: 768px) {
+  /* the menu is on the left */
+  .HubConfigDialog .nav .nav-item {
+    margin-bottom: 0;
+  }
+  .HubConfigDialog .nav .nav-link {
+    border-radius: 0.25rem 0 0 0.25rem;
+  }
+}
+
diff --git a/hubs/static/client/app/components/HubConfig/HubConfigDialog.js b/hubs/static/client/app/components/HubConfig/HubConfigDialog.js
new file mode 100644
index 0000000..6d00e05
--- /dev/null
+++ b/hubs/static/client/app/components/HubConfig/HubConfigDialog.js
@@ -0,0 +1,129 @@
+import React from 'react';
+import {
+  defineMessages,
+  FormattedMessage,
+  } from 'react-intl';
+import PropTypes from 'prop-types';
+import GeneralPanel from './HubConfigPanelGeneral';
+import UserPanel from './HubConfigPanelUser';
+import ChatPanel from './HubConfigPanelChat';
+import NotImplementedPanel from './HubConfigPanelNotImpl';
+import Modal from '../../components/Modal';
+import TabSet from '../../components/TabSet';
+
+
+const messages = defineMessages({
+  title: {
+    id: "hubs.core.config.title",
+    defaultMessage: "Hub settings",
+  },
+  general: {
+    id: "hubs.core.config.general",
+    defaultMessage: "General",
+  },
+  owners: {
+    id: "hubs.core.config.owners",
+    defaultMessage: "Owners",
+  },
+  members: {
+    id: "hubs.core.config.members",
+    defaultMessage: "Members",
+  },
+  chat: {
+    id: "hubs.core.config.chat",
+    defaultMessage: "Chat",
+  },
+  other: {
+    id: "hubs.core.config.other",
+    defaultMessage: "Other",
+  },
+  close: {
+    id: "hubs.core.config.close",
+    defaultMessage: "Close",
+  },
+  save_changes: {
+    id: "hubs.core.config.save_changes",
+    defaultMessage: "Save changes",
+  },
+});
+
+
+
+export default class HubConfigDialog extends React.Component {
+
+  render() {
+    const title = (
+      <FormattedMessage {...messages.title} />
+    );
+    const footer = (
+      <div className="d-flex justify-content-end w-100">
+        <button
+          type="button"
+          className="btn btn-secondary ml-3"
+          onClick={this.props.onCloseClicked}
+          >
+          <FormattedMessage {...messages.close} />
+        </button>
+        <button
+          type="button"
+          className="btn btn-primary ml-3"
+          onClick={this.props.onSaveClicked}
+          >
+          <FormattedMessage {...messages.save_changes} />
+        </button>
+      </div>
+    );
+
+    return (
+      <Modal
+        onCloseClicked={this.props.onCloseClicked}
+        isLarge={true}
+        title={title}
+        footer={footer}
+        cssClass="HubConfigDialog"
+        >
+        <TabSet
+          tabWrapperProps={{className: "row pt-0"}}
+          tabListClass="flex-md-column justify-content-center justify-content-md-start col-md-3 pt-2 pt-md-3 pb-md-4 pr-md-0 pl-md-2"
+          tabLinkClass="my-md-2"
+          tabContentClass="col-md-9 p-3 pb-4"
+          >
+          <GeneralPanel
+            hubConfig={this.props.hubConfig}
+            globalConfig={this.props.globalConfig}
+            tabTitle={<FormattedMessage {...messages.general} />}
+            handleChange={this.props.onConfigChange}
+            />
+          <UserPanel
+            users={this.props.users.owner}
+            globalConfig={this.props.globalConfig}
+            role="owner"
+            handleChange={this.props.onUserChange}
+            tabTitle={<FormattedMessage {...messages.owners} />}
+            urls={this.props.urls}
+            currentUser={this.props.currentUser}
+            />
+          <UserPanel
+            users={this.props.users.member}
+            globalConfig={this.props.globalConfig}
+            role="member"
+            handleChange={this.props.onUserChange}
+            tabTitle={<FormattedMessage {...messages.members} />}
+            urls={this.props.urls}
+            currentUser={this.props.currentUser}
+            />
+          <ChatPanel
+            hubConfig={this.props.hubConfig}
+            globalConfig={this.props.globalConfig}
+            tabTitle={<FormattedMessage {...messages.chat} />}
+            handleChange={this.props.onConfigChange}
+            />
+          <NotImplementedPanel
+            tabTitle={<FormattedMessage {...messages.other} />}
+            />
+        </TabSet>
+      </Modal>
+    );
+  }
+
+}
diff --git a/hubs/static/client/app/components/HubConfig/HubConfigPanelChat.js b/hubs/static/client/app/components/HubConfig/HubConfigPanelChat.js
new file mode 100644
index 0000000..f756e9d
--- /dev/null
+++ b/hubs/static/client/app/components/HubConfig/HubConfigPanelChat.js
@@ -0,0 +1,103 @@
+import React from 'react';
+import {
+  defineMessages,
+  FormattedMessage,
+  } from 'react-intl';
+
+
+const messages = defineMessages({
+  title: {
+    id: "hubs.core.config.chat.title",
+    defaultMessage: "Chat Settings",
+  },
+  intro_1: {
+    id: "hubs.core.config.chat.intro1",
+    defaultMessage: (
+      "If your team or project has an IRC channel associated with it, you "
+     +"can connect this hub to it via the Hubs chat widget."
+     ),
+  },
+  intro_2: {
+    id: "hubs.core.config.chat.intro2",
+    defaultMessage: "Please indicate your team's IRC channel below:",
+  },
+  channel_name: {
+    id: "hubs.core.config.chat.channel_name",
+    defaultMessage: "Channel name",
+  },
+  example: {
+    id: "hubs.core.config.chat.example",
+    defaultMessage: "Example:",
+  },
+  network: {
+    id: "hubs.core.config.chat.network",
+    defaultMessage: "IRC network",
+  },
+});
+
+
+export default class ChatPanel extends React.Component {
+
+  render() {
+    var networks = [],
+        channel = "#",
+        stillLoading = (
+          typeof this.props.hubConfig.chat_channel === "undefined"
+        );
+
+    if (this.props.hubConfig.chat_channel) {
+      channel = "#" + this.props.hubConfig.chat_channel.replace(/^#*/, "");
+    }
+
+    if (this.props.globalConfig.chat_networks) {
+      networks = this.props.globalConfig.chat_networks.map(
+        function(network, index) {
+          return (
+            <option value={network.domain} key={network.domain}>
+              {network.name} - {network.domain}
+            </option>
+          );
+        }.bind(this)
+      );
+    }
+
+    return (
+      <form onSubmit={(e) => {e.preventDefault();}}>
+        <FormattedMessage {...messages.title} tagName="h3" />
+        <FormattedMessage {...messages.intro_1} tagName="p" />
+        <FormattedMessage {...messages.intro_2} tagName="p" />
+        <div className="form-group">
+          <label htmlFor="hub-settings-chat-channel">
+            <FormattedMessage {...messages.channel_name} />
+          </label>
+          <input
+            type="text" className="form-control" name="chat_channel"
+            id="hub-settings-chat-channel"
+            disabled={stillLoading}
+            onChange={this.props.handleChange}
+            value={channel}
+            />
+          <p className="form-text text-muted">
+            <FormattedMessage {...messages.example} tagName="strong" />
+            <span className="ml-2">#fedora-devel</span>
+          </p>
+        </div>
+        <div className="form-group">
+          <label htmlFor="hub-settings-chat-domain">
+            <FormattedMessage {...messages.network} />
+          </label>
+          <select
+            name="chat_domain" className="form-control"
+            id="hub-settings-chat-domain"
+            disabled={stillLoading}
+            onChange={this.props.handleChange}
+            value={this.props.hubConfig.chat_domain || ""}
+            >
+            {networks}
+          </select>
+        </div>
+      </form>
+    );
+  }
+
+}
diff --git a/hubs/static/client/app/components/HubConfig/HubConfigPanelGeneral.js b/hubs/static/client/app/components/HubConfig/HubConfigPanelGeneral.js
new file mode 100644
index 0000000..9e5972a
--- /dev/null
+++ b/hubs/static/client/app/components/HubConfig/HubConfigPanelGeneral.js
@@ -0,0 +1,142 @@
+import React from 'react';
+import {
+  defineMessages,
+  FormattedMessage,
+  FormattedHTMLMessage,
+  } from 'react-intl';
+
+
+const messages = defineMessages({
+  title: {
+    id: "hubs.core.config.general.title",
+    defaultMessage: "General Settings",
+  },
+  intro: {
+    id: "hubs.core.config.general.intro",
+    defaultMessage: "Change your global hub parameters here.",
+  },
+  summary: {
+    id: "hubs.core.config.general.summary",
+    defaultMessage: "Summary",
+  },
+  summary_help: {
+    id: "hubs.core.config.general.summary_help",
+    defaultMessage: "This text will be displayed at the top of the hub.",
+  },
+  left_width: {
+    id: "hubs.core.config.general.left_width",
+    defaultMessage: "Left width",
+  },
+  left_width_help: {
+    id: "hubs.core.config.general.left_width_help",
+    defaultMessage: (
+      "Change the proportions of you hub's columns by setting the width of "
+     +"the left column (the right column's width will be {right_width})."
+      ),
+  },
+  visibility: {
+    id: "hubs.core.config.general.visibility",
+    defaultMessage: "Visibility",
+  },
+  visibility_help: {
+    id: "hubs.core.config.general.visibility_help",
+    defaultMessage: (
+      "Public: the hub is visible to anyone.<br />"
+     +"Private: the hub is only visible to members.<br />"
+     +"Preview: some widgets can be made visible to logged-in users only."
+      ),
+  },
+  avatar: {
+    id: "hubs.core.config.general.avatar",
+    defaultMessage: "Avatar",
+  },
+  avatar_help: {
+    id: "hubs.core.config.general.avatar_help",
+    defaultMessage: "An URL to you hub's main image or logo."
+  },
+});
+
+
+export default class GeneralPanel extends React.Component {
+
+  render() {
+    var stillLoading = (typeof this.props.hubConfig.summary === "undefined");
+    var visibilities = this.props.globalConfig.hub_visibility || [];
+    return (
+      <form onSubmit={(e) => {e.preventDefault();}}>
+        <FormattedMessage {...messages.title} tagName="h3" />
+        <FormattedMessage {...messages.intro} tagName="p" />
+        <div className="form-group">
+          <label htmlFor="hub-settings-general-summary">
+            <FormattedMessage {...messages.summary} />
+          </label>
+          <input
+            type="text" className="form-control" name="summary"
+            id="hub-settings-general-summary"
+            disabled={stillLoading}
+            onChange={this.props.handleChange}
+            value={this.props.hubConfig.summary || ""}
+            />
+          <p className="form-text text-muted">
+            <FormattedMessage {...messages.summary_help} />
+          </p>
+        </div>
+        <div className="form-group">
+          <label htmlFor="hub-settings-general-leftwidth">
+            <FormattedMessage {...messages.left_width} />
+          </label>
+          <input
+            type="number" className="form-control" name="left_width"
+            id="hub-settings-general-leftwidth"
+            disabled={stillLoading}
+            min="1" max="11"
+            onChange={this.props.handleChange}
+            value={this.props.hubConfig.left_width || ""}
+            />
+          <p className="form-text text-muted">
+            <FormattedMessage
+              {...messages.left_width_help}
+              values={{right_width: 12 - (this.props.hubConfig.left_width || 8)}}
+              />
+          </p>
+        </div>
+        <div className="form-group">
+          <label htmlFor="hub-settings-general-visibility">
+            <FormattedMessage {...messages.visibility} />
+          </label>
+          <select
+            className="form-control" name="visibility"
+            value={this.props.hubConfig.visibility || ""}
+            onChange={this.props.handleChange}
+            disabled={stillLoading}
+            >
+            {visibilities.map(function(value) {
+              return (
+                <option value={value} key={value}>{value}</option>
+                );
+            })}
+          </select>
+          <p className="form-text text-muted">
+            <FormattedHTMLMessage {...messages.visibility_help} />
+          </p>
+        </div>
+        <div className="form-group">
+          <label htmlFor="hub-settings-general-avatar">
+            <FormattedMessage {...messages.avatar} />
+          </label>
+          <input
+            type="text" className="form-control" name="avatar"
+            id="hub-settings-general-avatar"
+            disabled={stillLoading}
+            onChange={this.props.handleChange}
+            value={this.props.hubConfig.avatar || ""}
+            />
+          <p className="form-text text-muted">
+            <FormattedMessage {...messages.avatar_help} />
+          </p>
+        </div>
+      </form>
+    );
+  }
+
+}
diff --git a/hubs/static/client/app/components/HubConfig/HubConfigPanelNotImpl.js b/hubs/static/client/app/components/HubConfig/HubConfigPanelNotImpl.js
new file mode 100644
index 0000000..7ec90c0
--- /dev/null
+++ b/hubs/static/client/app/components/HubConfig/HubConfigPanelNotImpl.js
@@ -0,0 +1,14 @@
+import React from 'react';
+
+
+export default class NotImplementedPanel extends React.Component {
+
+  render() {
+    return (
+      <div>
+        Not implemented yet.
+      </div>
+    );
+  }
+
+}
diff --git a/hubs/static/client/app/components/HubConfig/HubConfigPanelUser.js b/hubs/static/client/app/components/HubConfig/HubConfigPanelUser.js
new file mode 100644
index 0000000..c516c19
--- /dev/null
+++ b/hubs/static/client/app/components/HubConfig/HubConfigPanelUser.js
@@ -0,0 +1,229 @@
+import React from 'react';
+import {
+  injectIntl,
+  defineMessages,
+  FormattedMessage,
+  } from 'react-intl';
+import CompletionInput from '../CompletionInput';
+
+
+const messages = defineMessages({
+  fullname: {
+    id: "hubs.core.config.users.fullname",
+    defaultMessage: "Full name",
+  },
+  username: {
+    id: "hubs.core.config.users.username",
+    defaultMessage: "Username",
+  },
+  change_role: {
+    id: "hubs.core.config.users.change_role",
+    defaultMessage: "Change role",
+  },
+  na: {
+    id: "hubs.core.config.users.na",
+    defaultMessage: "(N/A)",
+  },
+  remove_user: {
+    id: "hubs.core.config.users.remove_user",
+    defaultMessage: "(remove user)",
+  },
+  add_user: {
+    id: "hubs.core.config.users.add_user",
+    defaultMessage: "Add user...",
+  },
+  add: {
+    id: "hubs.core.config.users.add",
+    defaultMessage: "Add",
+  },
+});
+
+
+export default class UserPanel extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      addUser: null,
+      addUserInputContents: "",
+    };
+    this.handleAdd = this.handleAdd.bind(this);
+    this.handleAddChanged = this.handleAddChanged.bind(this);
+    this.handleAddSelected = this.handleAddSelected.bind(this);
+    this.filterSuggestedUsers = this.filterSuggestedUsers.bind(this);
+  }
+
+  handleAdd(e) {
+    e.preventDefault();
+    this.props.handleChange(this.state.addUser, null, this.props.role);
+    this.setState({addUser: null, addUserInputContents: ""});
+  }
+
+  handleAddChanged(e, username) {
+    this.setState({addUserInputContents: username});
+  }
+
+  handleAddSelected(addUser) {
+    this.setState({addUser});
+  }
+
+  filterSuggestedUsers(user) {
+    // Only suggest non-members.
+    return (this.props.users.indexOf(user) === -1);
+  }
+
+  render() {
+    var users = this.props.users.map(function(user) {
+      const locked = (
+        this.props.role === "owner" &&
+        this.props.currentUser.logged_in &&
+        user.username === this.props.currentUser.nickname
+      );
+      return (
+        <UserRow
+          key={user.username}
+          user={user}
+          role={this.props.role}
+          globalConfig={this.props.globalConfig}
+          onChange={this.props.handleChange}
+          locked={locked}
+          />
+        );
+    }.bind(this));
+
+    /*
+    const IntlCompletionInput = injectIntl(function(props) {
+        return (
+          <CompletionInput
+            inputProps={{
+              name: "username",
+              placeholder: props.intl.formatMessage(messages.add_user),
+            }}
+            url={(
+              props.urls.hubConfigSuggestUsers
+              + "?exclude-role=" + props.role
+            )}
+            onChange={this.handleAddInputChange}
+            />
+            );
+    }.bind(this));
+    */
+    const getSuggestionValue = (suggestion) => {
+      return suggestion.username;
+    };
+    const renderSuggestion = (suggestion) => (
+      <span>
+        {suggestion.username}
+        {" "}
+        <small className="text-muted float-right">
+          ({suggestion.fullname})
+        </small>
+      </span>
+    );
+
+    return (
+      <div>
+        <h3>{this.props.tabTitle}</h3>
+        {(this.props.role == "member") &&
+          <div>
+            <p className="text-muted">
+              [Membership requests will be shown here]
+            </p>
+            <hr />
+          </div>
+        }
+        <form
+          className="form-inline mb-3"
+          onSubmit={this.handleAdd}
+          >
+          <div className="form-group">
+            <FormattedMessage {...messages.add_user}>
+              {(message) => (
+                <CompletionInput
+                  inputProps={{
+                    name: "username",
+                    placeholder: message,
+                    value: this.state.addUserInputContents
+                  }}
+                  url={(
+                    this.props.urls.hubConfigSuggestUsers
+                    + "?exclude-role=" + this.props.role
+                  )}
+                  getSuggestionValue={getSuggestionValue}
+                  renderSuggestion={renderSuggestion}
+                  onChange={this.handleAddChanged}
+                  onSelect={this.handleAddSelected}
+                  />
+              )}
+            </FormattedMessage>
+            </div>
+          <button
+            className="btn btn-primary ml-3"
+            disabled={this.props.loading}
+            >
+            <FormattedMessage {...messages.add} />
+          </button>
+        </form>
+        {users.length !== 0 &&
+        <table className="table table-hover table-sm">
+          <thead>
+            <tr>
+              <FormattedMessage {...messages.fullname} tagName="th" />
+              <FormattedMessage {...messages.username} tagName="th" />
+              <FormattedMessage {...messages.change_role} tagName="th" />
+            </tr>
+          </thead>
+          <tbody>
+            {users}
+          </tbody>
+        </table>
+        }
+      </div>
+    );
+  }
+
+}
+
+
+class UserRow extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.handleChange = this.handleChange.bind(this);
+  }
+
+  handleChange(e) {
+    e.preventDefault();
+    this.props.onChange(this.props.user, this.props.role, e.target.value);
+  }
+
+  render() {
+    return (
+      <tr>
+        <td>{this.props.user.fullname}</td>
+        <td>{this.props.user.username}</td>
+        <td>
+          {this.props.locked ?
+            <FormattedMessage {...messages.na} />
+          :
+            <select
+              name={this.props.user.username}
+              value={this.props.role}
+              className="form-control"
+              onChange={this.handleChange}
+              >
+              {this.props.globalConfig.roles.map(function(role) {
+                return (
+                  <option value={role} key={role}>{role}</option>
+                  );
+              })}
+              <FormattedMessage {...messages.remove_user}>
+                {(value) => ( <option value="">{value}</option> )}
+              </FormattedMessage>
+            </select>
+          }
+        </td>
+      </tr>
+    );
+  }
+}
diff --git a/hubs/static/client/app/components/HubConfig/index.js b/hubs/static/client/app/components/HubConfig/index.js
new file mode 100644
index 0000000..8446004
--- /dev/null
+++ b/hubs/static/client/app/components/HubConfig/index.js
@@ -0,0 +1,122 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import {
+  defineMessages,
+  FormattedMessage,
+  } from 'react-intl';
+import { saveHub } from '../../core/actions/hub';
+import HubConfigDialog from './HubConfigDialog';
+import "./HubConfig.css";
+
+
+const messages = defineMessages({
+  button: {
+    id: "hubs.core.config.button",
+    defaultMessage: "Hub settings",
+  },
+});
+
+
+class HubConfig extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      config: null,
+      users: null,
+      isDialogOpen: false,
+    };
+    this.handleConfigChange = this.handleConfigChange.bind(this);
+    this.doUserChange = this.doUserChange.bind(this);
+    this.handleOpenClicked = this.handleOpenClicked.bind(this);
+    this.handleCloseClicked = this.handleCloseClicked.bind(this);
+    this.handleSaveClicked = this.handleSaveClicked.bind(this);
+  }
+
+  handleOpenClicked(e) {
+    e.preventDefault();
+    this.setState({
+      isDialogOpen: true,
+      config: this.props.hub.config,
+      users: this.props.hub.users,
+    });
+  }
+
+  handleCloseClicked(e) {
+    e.preventDefault();
+    this.setState({isDialogOpen: false});
+  }
+
+  handleConfigChange(e) {
+    const name = e.target.name, value = e.target.value;
+    this.setState((prevState, props) => {
+      prevState.config[name] = value;
+      return prevState;
+    });
+  }
+
+  doUserChange(user, oldRole, newRole) {
+    this.setState((prevState, props) => {
+      if (oldRole) {
+        // Remove previous role
+        prevState.users[oldRole] = prevState.users[oldRole].filter(
+          (u) => (u.username !== user.username)
+        );
+      }
+      if (newRole) {
+        prevState.users[newRole].push(user);
+      }
+      return {users: prevState.users};
+    });
+  }
+
+  handleSaveClicked(e) {
+    e.preventDefault();
+    this.props.dispatch(saveHub(this.state.config, this.state.users));
+    this.setState({isDialogOpen: false});
+  }
+
+  render() {
+    const openButton = (
+      <button
+        className="open-button btn btn-sm btn-secondary"
+        onClick={this.handleOpenClicked}
+        >
+        <i className="fa fa-cog mr-2" aria-hidden="true"></i>
+        <FormattedMessage {...messages.button} />
+      </button>
+    );
+    return (
+      <div className="HubConfig">
+        {openButton}
+        {this.state.isDialogOpen &&
+          <HubConfigDialog
+            onCloseClicked={this.handleCloseClicked}
+            onSaveClicked={this.handleSaveClicked}
+            hubConfig={this.state.config}
+            users={this.state.users}
+            globalConfig={this.props.globalConfig}
+            onConfigChange={this.handleConfigChange}
+            onUserChange={this.doUserChange}
+            urls={this.props.urls}
+            currentUser={this.props.currentUser}
+            />
+        }
+      </div>
+    );
+  }
+
+}
+
+
+const mapStateToProps = (state) => {
+  return {
+    hub: state.entities.hub,
+    globalConfig: state.globalConfig,
+    urls: state.urls,
+    currentUser: state.currentUser,
+  }
+};
+
+export default connect(mapStateToProps)(HubConfig);
diff --git a/hubs/static/client/app/components/HubHeader.css b/hubs/static/client/app/components/HubHeader.css
new file mode 100644
index 0000000..2407253
--- /dev/null
+++ b/hubs/static/client/app/components/HubHeader.css
@@ -0,0 +1,34 @@
+.HubHeader {
+  font-family: 'Open Sans', sans-serif;
+  /*
+  color: #373a3c;
+  width: 100%;
+  background: #f3f3f3 none repeat scroll 0 0;
+  border-bottom: 1px solid #ddd;
+  padding-bottom: .5rem;
+  */
+}
+
+.HubHeader img.avatar {
+    width: 70px;
+    float: left;
+    margin-right: 15px;
+    display: inline;
+    border: 5px solid #fff;
+}
+
+.HubHeader h2 {
+    font-weight: 500;
+    padding-top: .5rem;
+}
+
+@media (max-width:768px) {
+  .header h2.team {
+    top: 170px !important;
+  }
+}
+
+.HubHeader > .Spinner {
+    position: absolute;
+    left: 1rem;
+}
diff --git a/hubs/static/client/app/components/HubHeader.js b/hubs/static/client/app/components/HubHeader.js
new file mode 100644
index 0000000..509c9d1
--- /dev/null
+++ b/hubs/static/client/app/components/HubHeader.js
@@ -0,0 +1,74 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import Spinner from "./Spinner";
+import HubConfig from './HubConfig';
+import EditModeButton from './EditModeButton';
+import "./HubHeader.css";
+
+
+class HubHeader extends React.Component {
+
+  propTypes: {
+    hub: PropTypes.object.isRequired,
+    isLoading: PropTypes.bool,
+    currentUser: PropTypes.object,
+  }
+
+  render() {
+    return (
+      <div className="HubHeader">
+        { this.props.isLoading &&
+          <Spinner />
+        }
+        { this.props.hub.name &&
+          <div className="row">
+            <div className={`col-md-${this.props.hub.config.left_width}`}>
+              { this.props.hub.user_hub ?
+                <div>
+                  <img
+                    className="avatar"
+                    src={this.props.hub.config.avatar}
+                    />
+                  <h2 className="user mb-0">
+                    {this.props.hub.name}
+                  </h2>
+                  <h5 className="mt-1 mb-1">
+                    {this.props.hub.config.summary}
+                  </h5>
+                  <div className="clearfix"></div>
+                </div>
+              :
+                <div>
+                  <h2 className="team">
+                    {this.props.hub.name}
+                  </h2>
+                  <h5 className="mt-1 mb-1">
+                    {this.props.hub.config.summary}
+                  </h5>
+                </div>
+              }
+            </div>
+            { this.props.currentUser.can_config_hub &&
+              <div className={`col-md-${this.props.hub.config.right_width} text-center align-self-end`}>
+                <HubConfig />
+                <EditModeButton />
+              </div>
+            }
+          </div>
+        }
+      </div>
+    );
+  }
+}
+
+
+const mapStateToProps = (state) => {
+  return {
+    hub: state.entities.hub,
+    isLoading: state.entities.hub.isLoading,
+    currentUser: state.currentUser,
+  }
+};
+
+export default connect(mapStateToProps)(HubHeader);
diff --git a/hubs/static/client/app/components/HubPage.css b/hubs/static/client/app/components/HubPage.css
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/hubs/static/client/app/components/HubPage.css
diff --git a/hubs/static/client/app/components/HubPage.js b/hubs/static/client/app/components/HubPage.js
new file mode 100644
index 0000000..57ce5d0
--- /dev/null
+++ b/hubs/static/client/app/components/HubPage.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import {
+  defineMessages,
+  FormattedMessage,
+  } from 'react-intl';
+import PageStructure from './PageStructure';
+import WidgetsArea from './WidgetsArea';
+import HubHeader from './HubHeader';
+import { fetchHub } from '../core/actions/hub';
+import "./HubPage.css";
+
+
+class HubPage extends React.Component {
+
+  componentDidMount() {
+    this.props.dispatch(fetchHub());
+  }
+
+  render() {
+    return (
+      <PageStructure
+        cssClass="HubPage"
+        header={
+          <HubHeader />
+        }
+        content={
+          <WidgetsArea />
+        }
+        />
+    );
+  }
+}
+
+
+export default connect()(HubPage);
diff --git a/hubs/static/client/app/components/LeftMenu.css b/hubs/static/client/app/components/LeftMenu.css
new file mode 100644
index 0000000..1b0fd47
--- /dev/null
+++ b/hubs/static/client/app/components/LeftMenu.css
@@ -0,0 +1,27 @@
+.LeftMenu {
+  width: 100%;
+  padding: 0 15px;
+  z-index: 1;
+}
+
+.LeftMenu .nav {
+  background-color: #fcfcfc;
+  border: 1px solid #dddddd;
+}
+@media (min-width: 992px) {
+  .LeftMenu {
+    position: absolute;
+    padding: 0.5rem 0 0 0;
+  }
+}
+
+.LeftMenu a.nav-link {
+  color: #373a3c;
+  font-size: 85%;
+}
+
+.LeftMenu a.nav-link.active {
+  color: white;
+  /*background-color: #373a3c;*/
+}
+
diff --git a/hubs/static/client/app/components/LeftMenu.js b/hubs/static/client/app/components/LeftMenu.js
new file mode 100644
index 0000000..066cf6d
--- /dev/null
+++ b/hubs/static/client/app/components/LeftMenu.js
@@ -0,0 +1,76 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import "./LeftMenu.css";
+
+
+class LeftMenu extends React.Component {
+
+  propTypes: {
+    menu: PropTypes.array,
+  }
+  defaultProps: {
+    menu: [],
+  }
+
+  render() {
+    return (
+      <div className="LeftMenu">
+        <ul
+          className="nav nav-pills flex-lg-column mb-3 rounded"
+          role="navigation"
+          >
+          {this.props.menu.map((entry) => (
+            <LeftMenuEntry
+              key={entry.url}
+              {...entry}
+              />
+            ))
+          }
+        </ul>
+      </div>
+    );
+  }
+}
+
+const mapStateToProps = (state) => {
+  return {
+    menu: state.ui.leftMenu,
+  }
+};
+
+export default connect(mapStateToProps)(LeftMenu);
+
+
+class LeftMenuEntry extends React.Component {
+
+  propTypes: {
+    url: PropTypes.string.isRequired,
+    icon: PropTypes.string.isRequired,
+    text: PropTypes.string.isRequired,
+    cssClass: PropTypes.string,
+  }
+  defaultProps: {
+    cssClass: null,
+  }
+
+  render() {
+    const isActive = window.location.pathname.indexOf(this.props.url) !== -1;
+    let cssClass = "nav-link";
+    if (isActive) {
+      cssClass += " active";
+    }
+    if (this.props.cssClass) {
+      cssClass += " " + this.props.cssClass;
+    }
+    return (
+      <li className="nav-item">
+        <a className={cssClass} href={this.props.url}>
+          <i className={`fa fa-${this.props.icon} mr-2`}
+             aria-hidden="true"></i>
+          {this.props.text}
+        </a>
+      </li>
+    );
+  }
+}
diff --git a/hubs/static/client/app/components/Modal.js b/hubs/static/client/app/components/Modal.js
new file mode 100644
index 0000000..5a468f1
--- /dev/null
+++ b/hubs/static/client/app/components/Modal.js
@@ -0,0 +1,69 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+
+export default class Modal extends React.Component {
+
+  propTypes: {
+    isLarge: PropTypes.bool,
+    onCloseClicked: PropTypes.func,
+    title: PropTypes.node,
+    footer: PropTypes.node,
+    cssClass: PropTypes.string,
+  }
+  defaultProps: {
+    isLarge: false,
+  }
+
+  constructor(props) {
+    super(props);
+    this.handleCloseClicked = this.handleCloseClicked.bind(this);
+  }
+
+  handleCloseClicked(e) {
+    if (this.props.onCloseClicked) {
+      this.props.onCloseClicked(e);
+    }
+  }
+
+  stopPropagation(e) {
+    e.stopPropagation();
+  }
+
+  render() {
+    return (
+      <div className={this.props.cssClass}>
+        <div className="modal-backdrop show"></div>
+        <div
+          className="modal" style={{display: "block"}}
+          onClick={this.handleCloseClicked}
+          >
+          <div
+            className={"modal-dialog" + (this.props.isLarge ? " modal-lg" : "")}
+            onClick={this.stopPropagation}
+            >
+            <div className="modal-content">
+                <div className="modal-header">
+                  <h5 className="modal-title">
+                    {this.props.title}
+                  </h5>
+                  <button
+                    type="button" className="close" aria-label="Close"
+                    onClick={this.handleCloseClicked}
+                    >
+                    <span aria-hidden="true">&times;</span>
+                  </button>
+                </div>
+                <div className="modal-body">
+                  {this.props.children}
+                </div>
+                <div className="modal-footer">
+                  {this.props.footer}
+                </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
diff --git a/hubs/static/client/app/components/Modal.jsx b/hubs/static/client/app/components/Modal.jsx
deleted file mode 100644
index 3b18312..0000000
--- a/hubs/static/client/app/components/Modal.jsx
+++ /dev/null
@@ -1,121 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-
-export default class Modal extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.state = {
-      open: false,
-    }
-    this.onShow = this.onShow.bind(this);
-    this.onShown = this.onShown.bind(this);
-    this.onHide = this.onHide.bind(this);
-    this.onHidden = this.onHidden.bind(this);
-    this.hide = this.hide.bind(this);
-    this.show = this.show.bind(this);
-    this.getModalBody = this.getModalBody.bind(this);
-    this.getModalFooter = this.getModalFooter.bind(this);
-  }
-
-  componentDidMount() {
-    if (this.domNode) {
-      $(this.domNode).on("show.bs.modal", this.onShow);
-      $(this.domNode).on("shown.bs.modal", this.onShown);
-      $(this.domNode).on("hide.bs.modal", this.onHide);
-      $(this.domNode).on("hidden.bs.modal", this.onHidden);
-    }
-  }
-
-  componentWillUnmount() {
-    if (this.domNode) {
-      $(this.domNode).off("show.bs.modal", this.onShow);
-      $(this.domNode).off("shown.bs.modal", this.onShown);
-      $(this.domNode).off("hide.bs.modal", this.onHide);
-      $(this.domNode).off("hidden.bs.modal", this.onHidden);
-    }
-  }
-
-  onShow(e) {
-    if (this.props.onShow) { this.props.onShow(); }
-  }
-  onShown(e) {
-    if (this.props.onShown) { this.props.onShown(); }
-  }
-  onHide(e) {
-    if (this.props.onHide) { this.props.onHide(); }
-  }
-  onHidden(e) {
-    if (this.props.onHidden) { this.props.onHidden(); }
-  }
-
-  hide(callback) {
-    if (!this.domNode) { return; }
-    var domNode = $(this.domNode);
-    if (callback) { domNode.one("hidden.bs.modal", callback); }
-    domNode.modal("hide");
-  }
-
-  show(callback) {
-    if (!this.domNode) { return; }
-    var domNode = $(this.domNode);
-    if (callback) { domNode.one("shown.bs.modal", callback); }
-    domNode.modal("show");
-  }
-
-  componentWillUpdate(nextProps, nextState) {
-    if (!this.domNode) { return; }
-    const domNode = $(this.domNode);
-    if (domNode.is(":visible") && !nextProps.open) {
-      this.hide();
-    }
-  }
-
-  componentDidUpdate() {
-    if (!this.domNode) { return; }
-    const domNode = $(this.domNode);
-    if (this.props.open && !domNode.is(":visible")) {
-      this.show();
-    }
-  }
-
-  getModalBody() {
-    if (!this.props.data) { return ""; }
-  }
-
-  getModalFooter() {
-    return null;
-  }
-
-  render() {
-    var footer = this.getModalFooter();
-    return (
-      <div className="modal fade" role="dialog" ref={(ref) => this.domNode = ref}>
-        <div className="modal-dialog" role="document">
-          <div className="modal-content">
-            <div className="modal-header">
-              <h4 className="modal-title">{this.props.title}</h4>
-              <button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
-            </div>
-            <div className="modal-body">
-              {this.getModalBody()}
-            </div>
-            {footer ?
-            <div className="modal-footer">
-              {footer}
-            </div>
-             : null}
-          </div>
-        </div>
-      </div>
-    );
-  }
-}
-Modal.propTypes = {
-    open: PropTypes.bool,
-    data: PropTypes.object
-};
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/components/NotifIcon.js b/hubs/static/client/app/components/NotifIcon.js
new file mode 100644
index 0000000..9330e99
--- /dev/null
+++ b/hubs/static/client/app/components/NotifIcon.js
@@ -0,0 +1,55 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+
+
+class NotifIcon extends React.PureComponent {
+
+  constructor(props) {
+    super(props);
+    this.notifTimeout = null;
+    this.changeNotifyIcon = this.changeNotifyIcon.bind(this);
+  }
+
+  componentDidUpdate(prevProps, prevState) {
+    // Icon to red on disconnect, icon to green on reconnect.
+    if (!prevProps.sseConnected && this.props.sseConnected) {
+      // Connected
+      this.changeNotifyIcon("green");
+    } else if (prevProps.sseConnected && !this.props.sseConnected) {
+      // Disconnected
+      this.changeNotifyIcon("red");
+    }
+  }
+
+  changeNotifyIcon(color) {
+    // Set the notify icon for 5s
+    if (color == "green") {
+      color = "#33ff00";
+    } else if (color == "red") {
+      color = "#eb361e";
+    }
+    window.Notificon(color);
+    if (this.notifTimeout) { clearTimeout(this.notifTimeout); }
+    this.notifTimeout = setTimeout(() => (window.Notificon()), 5000);
+  }
+
+  render() {
+    return null;
+  }
+}
+NotifIcon.propTypes = {
+  sseConnected: PropTypes.bool,
+}
+NotifIcon.defaultProps = {
+  sseConnected: false,
+}
+
+
+const mapStateToProps = (state) => {
+  return {
+    sseConnected: state.sse.connected,
+  }
+};
+
+export default connect(mapStateToProps)(NotifIcon);
diff --git a/hubs/static/client/app/components/PageStructure.js b/hubs/static/client/app/components/PageStructure.js
new file mode 100644
index 0000000..ee6cd14
--- /dev/null
+++ b/hubs/static/client/app/components/PageStructure.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import FlashMessages from './FlashMessages';
+import LeftMenu from './LeftMenu';
+
+
+export default class PageStructure extends React.Component {
+
+  render() {
+    return (
+      <div className={this.props.cssClass}>
+        <header>
+          <FlashMessages />
+          <div className="row">
+            <div className="col-lg-2">
+              <LeftMenu />
+            </div>
+            <div className="col-lg-10">
+              {this.props.header}
+            </div>
+          </div>
+        </header>
+
+        <div className="container-fluid">
+          <div className="row">
+            <div className="offset-lg-2 col-lg-10 px-0">
+              {this.props.content}
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
diff --git a/hubs/static/client/app/components/SSESource.js b/hubs/static/client/app/components/SSESource.js
new file mode 100644
index 0000000..930a38c
--- /dev/null
+++ b/hubs/static/client/app/components/SSESource.js
@@ -0,0 +1,82 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import ReconnectingEventSource from "reconnecting-eventsource";
+import {
+  sseEvent,
+  sseConnected,
+  sseDisconnected
+} from "../core/actions/sse";
+
+
+const MESSAGE_TYPES = [
+  "hubs:widget-updated",
+  "hubs:new-notification",
+];
+
+
+class SSESource extends React.PureComponent {
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      isEnabled: true,
+    }
+    this.source = null;
+    this.notifTimeout = null;
+    this.setupSource = this.setupSource.bind(this);
+  }
+
+  componentDidMount() {
+    if (!this.props.url || !window.EventSource) {
+      // Auto-update is disabled.
+      this.setState({isEnabled: false});
+    } else {
+      this.setupSource();
+    }
+  }
+
+  componentWillUnmount() {
+    if (this.source) {
+      this.source.close();
+    }
+    //window.removeEventListener("beforeunload", this.tearDownSSESource);
+  }
+
+  setupSource() {
+    this.source = new ReconnectingEventSource(this.props.url),
+    MESSAGE_TYPES.forEach((msgType) => {
+      this.source.addEventListener(msgType, (e) => {
+        this.props.dispatch(sseEvent(msgType, JSON.parse(e.data)));
+      });
+    });
+    this.source.onopen = () => (
+      this.props.dispatch(sseConnected(this.source))
+    );
+    this.source.onerror = () => (
+      this.props.dispatch(sseDisconnected(this.source))
+    );
+    // This is necessary to avoid the error message being displayed right
+    // before the user changes page.
+    // Pretty much like: https://bugzilla.mozilla.org/show_bug.cgi?id=833462
+    window.addEventListener("beforeunload", () => {
+      this.source.onerror = () => (null);
+    });
+  }
+
+  render() {
+    return null;
+  }
+}
+SSESource.propTypes = {
+  url: PropTypes.string.isRequired,
+}
+
+
+const mapStateToProps = (state) => {
+  return {
+    url: state.urls.sse,
+  }
+};
+
+export default connect(mapStateToProps)(SSESource);
diff --git a/hubs/static/client/app/components/SimpleWidget.js b/hubs/static/client/app/components/SimpleWidget.js
new file mode 100644
index 0000000..9a62fe5
--- /dev/null
+++ b/hubs/static/client/app/components/SimpleWidget.js
@@ -0,0 +1,86 @@
+import React from 'react';
+import {
+  widgetDidUpdate,
+  widgetWillUpdate
+  } from "../core/actions/widget";
+import { backendCall } from "../core/utils";
+import SimpleWidgetConfig from './SimpleWidgetConfig';
+import WidgetChrome from './WidgetChrome';
+
+
+export default class SimpleWidget extends React.PureComponent {
+
+  propTypes: {
+    widget: PropTypes.object.isRequired,
+    editMode: PropTypes.bool,
+    needsUpdate: PropTypes.bool,
+  }
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      isLoading: false,
+      contents: "",
+    };
+  }
+
+  componentDidMount() {
+    if (!this.props.editMode) {
+      this.loadFromServer();
+    }
+  }
+
+  componentDidUpdate(prevProps, prevState) {
+    if (this.props.needsUpdate && !this.props.editMode && !this.state.isLoading) {
+      this.loadFromServer();
+    }
+  }
+
+  loadFromServer() {
+    this.props.dispatch(widgetWillUpdate(this.props.widget.idx));
+    this.setState({ isLoading: true });
+    let url = this.props.widget.contentUrl;
+    if (this.props.editMode) {
+      url += "?edit=1";
+    }
+    return backendCall(url).catch(
+      (error) => (
+        `<div class="text-danger">Cannot display this widget: ${error.message}</div>`
+      )
+    ).then(
+      (contents) => {
+        this.setState({
+          isLoading: false, contents
+        }, () => (
+          this.props.dispatch(widgetDidUpdate(this.props.widget.idx))
+        ));
+      }
+    );
+  }
+
+  render() {
+    if (this.props.widget.hiddenIfEmpty) {
+      if (
+        this.state.isLoading ||
+        !this.props.editMode && (
+          !this.state.contents || !this.state.contents.trim()
+        )
+      ) {
+        // Hide when loading and when the contents is empty in view mode.
+        return null;
+      }
+    }
+    return (
+      <WidgetChrome
+        widget={this.props.widget}
+        editMode={this.props.editMode}
+        >
+        { this.state.isLoading &&
+          <div className="text-muted">Loading...</div>
+          ||
+          <div dangerouslySetInnerHTML={{__html: this.state.contents}} />
+        }
+      </WidgetChrome>
+    );
+  }
+}
diff --git a/hubs/static/client/app/components/SimpleWidgetConfig.js b/hubs/static/client/app/components/SimpleWidgetConfig.js
new file mode 100644
index 0000000..f323b52
--- /dev/null
+++ b/hubs/static/client/app/components/SimpleWidgetConfig.js
@@ -0,0 +1,42 @@
+import React from 'react';
+
+
+export default class SimpleWidgetConfig extends React.Component {
+
+  propTypes: {
+    widget: PropTypes.object.isRequired,
+    values: PropTypes.object.isRequired,
+    setConfig: PropTypes.func.isRequired,
+  }
+
+  render() {
+    let fields = this.props.widget.params.map((field) => {
+      return (
+        <fieldset className="form-group" key={field.name}>
+          <strong>{field.label}</strong>
+          <input
+            className="form-control"
+            type="text"
+            value={this.props.values[field.name] || ""}
+            name={field.name}
+            onChange={(e) => {
+              this.props.setConfig(e.target.name, e.target.value)
+            }}
+            />
+          { field.help &&
+            <small className="text-muted">{field.help}</small>
+          }
+        </fieldset>
+      );
+    });
+    return (
+      <div>
+        { fields.length === 0 ?
+          <p>Nothing to configure</p>
+          :
+          fields
+        }
+      </div>
+    );
+  }
+}
diff --git a/hubs/static/client/app/components/Spinner/Spinner.css b/hubs/static/client/app/components/Spinner/Spinner.css
new file mode 100644
index 0000000..97b52cb
--- /dev/null
+++ b/hubs/static/client/app/components/Spinner/Spinner.css
@@ -0,0 +1,12 @@
+.Spinner {
+   width: 50px;
+   height: 10px;
+   vertical-align: bottom;
+}
+
+.SpinnerCircle {
+  width: 32px;
+  height: 32px;
+  line-height: 32px;
+  vertical-align: bottom;
+}
diff --git a/hubs/static/client/app/components/Spinner/index.js b/hubs/static/client/app/components/Spinner/index.js
new file mode 100644
index 0000000..4979621
--- /dev/null
+++ b/hubs/static/client/app/components/Spinner/index.js
@@ -0,0 +1,31 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import "./Spinner.css";
+import SpinnerImg from "./spinner.gif";
+import SpinnerCircleImg from "./spinner-circle.gif";
+
+
+export default class Spinner extends React.Component {
+
+  render() {
+    let img, className;
+    if (this.props.circle) {
+      className = "SpinnerCircle";
+      img = SpinnerCircleImg;
+    } else {
+      className = "Spinner";
+      img = SpinnerImg;
+    }
+    return (
+      <div className={className}>
+        <img src={img} />
+      </div>
+    );
+  }
+}
+Spinner.propTypes = {
+  circle: PropTypes.bool
+}
+Spinner.defaultProps = {
+  circle: false
+}
diff --git a/hubs/static/client/app/components/Spinner/spinner-circle.gif b/hubs/static/client/app/components/Spinner/spinner-circle.gif
new file mode 100644
index 0000000..d0bce15
Binary files /dev/null and b/hubs/static/client/app/components/Spinner/spinner-circle.gif differ
diff --git a/hubs/static/client/app/components/Spinner/spinner.gif b/hubs/static/client/app/components/Spinner/spinner.gif
new file mode 100644
index 0000000..8a3ea9f
Binary files /dev/null and b/hubs/static/client/app/components/Spinner/spinner.gif differ
diff --git a/hubs/static/client/app/components/Streams.js b/hubs/static/client/app/components/Streams.js
new file mode 100644
index 0000000..b2b0ff3
--- /dev/null
+++ b/hubs/static/client/app/components/Streams.js
@@ -0,0 +1,193 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import {
+  defineMessages,
+  FormattedMessage,
+  } from 'react-intl';
+import {
+  streamWillUpdate,
+  streamDidUpdate,
+  } from "../core/actions/stream";
+import SSESource from "./SSESource";
+import TabSet from '../components/TabSet';
+import ItemsGetter from '../components/feed/ItemsGetter';
+import Feed from '../components/feed/Feed';
+
+
+class Streams extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      notifItems: [],
+      savedItems: [],
+    };
+    this.handleStreamData = this.handleStreamData.bind(this);
+    this.handleStreamRequestStart = this.handleStreamRequestStart.bind(this);
+    this.handleSavedData = this.handleSavedData.bind(this);
+    this.handleSave = this.handleSave.bind(this);
+    this.handleUnsave = this.handleUnsave.bind(this);
+  }
+
+  handleStreamData(data) {
+    this.setState({
+      notifItems: data
+    },
+      () => (
+        this.props.dispatch(streamDidUpdate())
+      )
+    );
+  }
+
+  handleStreamRequestStart() {
+    this.props.dispatch(streamWillUpdate());
+  }
+
+  handleSavedData(data) {
+    this.setState({savedItems: data});
+  }
+
+  handleSave(item) {
+    if (!this.props.savedUrl) { return; }
+    const payload = {
+      link: item.link,
+      markup: item.markup,
+      secondary_icon: item.secondary_icon,
+      dom_id: item.dom_id,
+    };
+    $.ajax({
+      type: 'POST',
+      url: this.props.savedUrl,
+      data: JSON.stringify(payload),
+      contentType: 'application/json',
+      success: (data) => {
+        this.setState((prevState, props) => {
+          prevState.savedItems.push(data.data);
+          return {savedItems: prevState.savedItems};
+        });
+      },
+    });
+  }
+
+  handleUnsave(item) {
+    if (!this.props.savedUrl) { return; }
+    var updateSavedItems = (item) => {
+      this.setState((prevState, props) => {
+        var items = prevState.savedItems.filter(
+          (currentItem) => (currentItem.dom_id !== item.dom_id)
+        );
+        return {savedItems: items};
+      });
+    };
+    if (item.idx) {
+      // Already saved
+      $.ajax({
+        type: 'DELETE',
+        url: `${this.props.savedUrl}${item.idx}/`,
+        success: () => { updateSavedItems(item) },
+      });
+    } else {
+      updateSavedItems(item);
+    }
+  }
+
+  render() {
+    // Add saved state.
+    var savedItemsIds = this.state.savedItems.map((item) => {
+      return item.dom_id;
+    });
+    var notifs = this.state.notifItems.map((item) => {
+      item.saved = (savedItemsIds.indexOf(item.dom_id) !== -1);
+      return item;
+    });
+
+    return (
+      <div className="Streams">
+        <TabSet tabListClass="mb-3">
+          <FeedPanel
+            username={this.props.username}
+            tabTitle="My Stream"
+            >
+            <ItemsGetter
+              url={this.props.notificationsUrl}
+              useSSE={true}
+              handleData={this.handleStreamData}
+              needsUpdate={false}
+              >
+              <Feed
+                items={notifs}
+                handleSave={this.handleSave}
+                handleUnsave={this.handleUnsave}
+                />
+            </ItemsGetter>
+          </FeedPanel>
+          <FeedPanel
+            username={this.props.username}
+            tabTitle="My Actions"
+            >
+            <Feed
+              items={this.state.notifItems}
+              />
+          </FeedPanel>
+          <FeedPanel
+            username={this.props.username}
+            tabTitle="Saved Notifications"
+            >
+            <ItemsGetter
+              url={this.props.savedUrl}
+              handleData={this.handleSavedData}
+              useSSE={false}
+              >
+              <Feed
+                items={this.state.savedItems}
+                handleUnsave={this.handleUnsave}
+                />
+            </ItemsGetter>
+          </FeedPanel>
+        </TabSet>
+
+        <SSESource />
+      </div>
+    );
+  }
+}
+
+
+const mapStateToProps = (state) => {
+  return {
+    savedUrl: state.urls.saved,
+    notificationsUrl: state.urls.notifications,
+    username: state.currentUser.nickname,
+  }
+};
+
+export default connect(mapStateToProps)(Streams);
+
+
+
+class FeedPanel extends React.Component {
+
+  render() {
+    const filters_url = `https://apps.fedoraproject.org/notifications/${this.props.username}.id.fedoraproject.org/`;
+
+    return (
+      <div>
+        <div className="input-group">
+          <input
+            type="search" className="form-control"
+            placeholder="Search this activity stream..."
+            aria-describedby="searchform-addon" />
+          <span className="input-group-addon">
+            <span><i className="fa fa-search" aria-hidden="true"></i></span>
+          </span>
+        </div>
+        <br/>
+        <div className="alert alert-warning" role="alert">
+          This stream is filtered. <a href={filters_url} target="_blank">View Filters</a>
+        </div>
+        {this.props.children}
+      </div>
+    );
+  }
+
+}
diff --git a/hubs/static/client/app/components/StreamsHeader.js b/hubs/static/client/app/components/StreamsHeader.js
new file mode 100644
index 0000000..7a6900f
--- /dev/null
+++ b/hubs/static/client/app/components/StreamsHeader.js
@@ -0,0 +1,15 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+
+export default class StreamsHeader extends React.Component {
+
+  render() {
+    return (
+      <div className="StreamsHeader">
+        <h1>My Stream</h1>
+        <p>Notifications, actions, and other things of interest to me</p>
+      </div>
+    );
+  }
+}
diff --git a/hubs/static/client/app/components/StreamsPage.js b/hubs/static/client/app/components/StreamsPage.js
new file mode 100644
index 0000000..3206280
--- /dev/null
+++ b/hubs/static/client/app/components/StreamsPage.js
@@ -0,0 +1,28 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import {
+  defineMessages,
+  FormattedMessage,
+  } from 'react-intl';
+import PageStructure from './PageStructure';
+import StreamsHeader from './StreamsHeader';
+import Streams from './Streams';
+
+
+export default class StreamsPage extends React.Component {
+
+  render() {
+    return (
+      <PageStructure
+        cssClass="StreamsPage"
+        header={
+          <StreamsHeader />
+        }
+        content={
+          <Streams />
+        }
+        />
+    );
+  }
+}
diff --git a/hubs/static/client/app/components/Tab.js b/hubs/static/client/app/components/Tab.js
new file mode 100644
index 0000000..1084065
--- /dev/null
+++ b/hubs/static/client/app/components/Tab.js
@@ -0,0 +1,27 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+
+export default class Tab extends React.Component {
+  render() {
+    var linkClass = `nav-link ${this.props.tabLinkClass}`;
+    if (this.props.active) { linkClass += " active"; }
+    return (
+      <li className="nav-item">
+        <a href="#" role="tab"
+           className={linkClass}
+           onClick={this.props.linkClicked}
+           >
+          {this.props.children}
+        </a>
+      </li>
+    );
+  }
+}
+
+Tab.propTypes = {
+  tabLinkClass: PropTypes.string,
+};
+Tab.defaultProps = {
+  tabLinkClass: '',
+};
diff --git a/hubs/static/client/app/components/Tab.jsx b/hubs/static/client/app/components/Tab.jsx
deleted file mode 100644
index a26e1b7..0000000
--- a/hubs/static/client/app/components/Tab.jsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-
-export default class Tab extends React.Component {
-  render() {
-    var linkClass = `nav-link ${this.props.tabLinkClass}`;
-    if (this.props.active) { linkClass += " active"; }
-    return (
-      <li className="nav-item">
-        <a href="#" role="tab"
-           className={linkClass}
-           onClick={this.props.linkClicked}
-           >
-          {this.props.children}
-        </a>
-      </li>
-    );
-  }
-}
-
-Tab.propTypes = {
-  tabLinkClass: PropTypes.string,
-};
-Tab.defaultProps = {
-  tabLinkClass: '',
-};
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/components/TabPanel.js b/hubs/static/client/app/components/TabPanel.js
new file mode 100644
index 0000000..a72dcee
--- /dev/null
+++ b/hubs/static/client/app/components/TabPanel.js
@@ -0,0 +1,13 @@
+import React from 'react';
+
+
+export default class TabPanel extends React.Component {
+  render() {
+    return (
+      <div role="tabpanel"
+           className={"tab-pane" + (this.props.active ? " active" : "")}>
+        {this.props.children}
+      </div>
+    );
+  }
+}
diff --git a/hubs/static/client/app/components/TabPanel.jsx b/hubs/static/client/app/components/TabPanel.jsx
deleted file mode 100644
index 7d3a4ee..0000000
--- a/hubs/static/client/app/components/TabPanel.jsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import React from 'react';
-
-
-export default class TabPanel extends React.Component {
-  render() {
-    return (
-      <div role="tabpanel"
-           className={"tab-pane" + (this.props.active ? " active" : "")}>
-        {this.props.children}
-      </div>
-    );
-  }
-}
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/components/TabSet.js b/hubs/static/client/app/components/TabSet.js
new file mode 100644
index 0000000..25dad18
--- /dev/null
+++ b/hubs/static/client/app/components/TabSet.js
@@ -0,0 +1,71 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Tab from './Tab';
+import TabPanel from './TabPanel';
+
+
+export default class TabSet extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      activeTab: 0
+    }
+  }
+
+  linkClicked(e, index) {
+    e.preventDefault();
+    this.setState({activeTab: index});
+  }
+
+  render() {
+    var tabLinks = this.props.children.map(function(panel, index) {
+      return (
+        <Tab active={this.state.activeTab === index}
+             linkClicked={(e) => this.linkClicked(e, index)}
+             key={index.toString()}>
+          {panel.props.tabTitle}
+        </Tab>
+        );
+    }.bind(this));
+
+    var tabPanels = this.props.children.map(function(panel, index) {
+      return (
+        <TabPanel
+          active={this.state.activeTab === index}
+          key={index.toString()}>
+          {panel}
+        </TabPanel>
+        );
+    }.bind(this));
+
+    return (
+      <div {...this.props.tabWrapperProps}>
+        <ul role="tablist"
+            className={`nav nav-${this.props.tabType} ${this.props.tabListClass}`}
+            >
+          {tabLinks}
+        </ul>
+        <div
+          className={"tab-content " + this.props.tabContentClass}
+          >
+          {tabPanels}
+        </div>
+      </div>
+    );
+  }
+
+}
+
+TabSet.propTypes = {
+  tabWrapperProps: PropTypes.object,
+  tabType: PropTypes.string,
+  tabListClass: PropTypes.string,
+  tabContentClass: PropTypes.string,
+};
+TabSet.defaultProps = {
+  tabWrapperProps: {},
+  tabType: 'tabs',
+  tabListClass: '',
+  tabContentClass: '',
+};
diff --git a/hubs/static/client/app/components/TabSet.jsx b/hubs/static/client/app/components/TabSet.jsx
deleted file mode 100644
index c3064c5..0000000
--- a/hubs/static/client/app/components/TabSet.jsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Tab from './Tab.jsx';
-import TabPanel from './TabPanel.jsx';
-
-
-export default class TabSet extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.state = {
-      activeTab: 0
-    }
-  }
-
-  linkClicked(e, index) {
-    e.preventDefault();
-    this.setState({activeTab: index});
-  }
-
-  render() {
-    var tabLinks = this.props.children.map(function(panel, index) {
-      return (
-        <Tab active={this.state.activeTab === index}
-             linkClicked={(e) => this.linkClicked(e, index)}
-             key={index.toString()}>
-          {panel.props.tabTitle}
-        </Tab>
-        );
-    }.bind(this));
-
-    var tabPanels = this.props.children.map(function(panel, index) {
-      return (
-        <TabPanel
-          active={this.state.activeTab === index}
-          key={index.toString()}>
-          {panel}
-        </TabPanel>
-        );
-    }.bind(this));
-
-    return (
-      <div {...this.props.tabWrapperProps}>
-        <ul role="tablist"
-            className={`nav nav-${this.props.tabType} ${this.props.tabListClass}`}
-            >
-          {tabLinks}
-        </ul>
-        <div
-          className={"tab-content " + this.props.tabContentClass}
-          >
-          {tabPanels}
-        </div>
-      </div>
-    );
-  }
-
-}
-
-TabSet.propTypes = {
-  tabWrapperProps: PropTypes.object,
-  tabType: PropTypes.string,
-  tabListClass: PropTypes.string,
-  tabContentClass: PropTypes.string,
-};
-TabSet.defaultProps = {
-  tabWrapperProps: {},
-  tabType: 'tabs',
-  tabListClass: '',
-  tabContentClass: '',
-};
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/components/VisibilitySelect.js b/hubs/static/client/app/components/VisibilitySelect.js
new file mode 100644
index 0000000..7eedaa1
--- /dev/null
+++ b/hubs/static/client/app/components/VisibilitySelect.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+
+export default class VisibilitySelect extends React.Component {
+
+  propTypes: {
+    selectedVisibility: PropTypes.string.isRequired,
+    setConfig: PropTypes.func.isRequired,
+  }
+
+  constructor(props) {
+    super(props);
+    this.handleChange = this.handleChange.bind(this);
+  }
+
+  handleChange(e) {
+    this.props.setConfig(e.target.name, e.target.value);
+  }
+
+  render() {
+    return (
+      <fieldset className="form-group">
+        <label htmlFor="visibility">Visibility:</label>
+        <select
+          className="form-control"
+          name="visibility"
+          value={this.props.selectedVisibility || "public"}
+          onChange={this.handleChange}
+          >
+          <option value="public">Public</option>
+          <option value="restricted">Restricted</option>
+        </select>
+        <small className="text-muted">
+          Restricted widgets will only be visible by logged-in users.
+        </small>
+      </fieldset>
+    );
+  }
+}
diff --git a/hubs/static/client/app/components/Widget.js b/hubs/static/client/app/components/Widget.js
new file mode 100644
index 0000000..880c1c4
--- /dev/null
+++ b/hubs/static/client/app/components/Widget.js
@@ -0,0 +1,74 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import {
+  makeLoadable,
+  makeLoadingComponent
+} from '../core/utils';
+import SimpleWidget from '../components/SimpleWidget';
+
+/*
+import HalpWidget from '../widgets/halp/Widget';
+import FeedWidget from '../widgets/feed/Widget';
+*/
+
+
+class Widget extends React.PureComponent {
+
+  propTypes: {
+    widget: PropTypes.array.isRequired,
+    editMode: PropTypes.bool,
+    needsUpdate: PropTypes.bool,
+  }
+  defaultProps: {
+    editMode: false,
+    needsUpdate: false,
+  }
+
+  render() {
+    let widgetComponent;
+    if (!this.props.editMode && this.props.widget.isReact) {
+      const AsyncComponent = makeLoadable(
+        () => import(`../widgets/${this.props.widget.component}/Widget`),
+        makeLoadingComponent(
+          "Loading widget...",
+          "Sorry, there was a problem loading the widget."
+        )
+      );
+      /*
+      console.log(this.props.widget);
+      let AsyncComponent = null;
+      if (this.props.widget.component === "halp") { AsyncComponent = HalpWidget; }
+      if (this.props.widget.component === "feed") { AsyncComponent = FeedWidget; }
+      */
+      widgetComponent = (
+        <AsyncComponent {...this.props} />
+      );
+    } else {
+      widgetComponent = (
+        <SimpleWidget {...this.props} />
+      );
+    }
+    return (
+      <div
+        key={this.props.widget.idx}
+        id={`widget-${this.props.widget.idx}`}
+        className={"widget" + (this.props.editMode ? " widget-edit" : "")}
+        >
+        {widgetComponent}
+      </div>
+    );
+  }
+}
+
+
+const mapStateToProps = (state, ownProps) => {
+  return {
+    editMode: state.ui.widgetsEditMode,
+    needsUpdate: (
+      state.entities.widgets.needUpdate.indexOf(ownProps.widget.idx) !== -1
+    ),
+  }
+};
+
+export default connect(mapStateToProps)(Widget);
diff --git a/hubs/static/client/app/components/WidgetChrome.css b/hubs/static/client/app/components/WidgetChrome.css
new file mode 100644
index 0000000..6878ef8
--- /dev/null
+++ b/hubs/static/client/app/components/WidgetChrome.css
@@ -0,0 +1,28 @@
+.widget {
+  font-family: 'Open Sans', sans-serif;
+  margin-bottom: .5rem;
+}
+
+.widget .card {
+  width: 100%;
+}
+
+.widget-buttons {
+  font-size: 80%;
+  position: absolute;
+  top: 0;
+  right: 0;
+  padding: .3rem;
+}
+
+.widget .widget-buttons a {
+  margin-right: .4rem;
+  color: #373a3c;
+}
+.widget .widget-buttons a:hover {
+  text-decoration: none;
+}
+
+.widget-edit .card-header {
+    cursor: ns-resize;
+}
diff --git a/hubs/static/client/app/components/WidgetChrome.js b/hubs/static/client/app/components/WidgetChrome.js
new file mode 100644
index 0000000..374b610
--- /dev/null
+++ b/hubs/static/client/app/components/WidgetChrome.js
@@ -0,0 +1,83 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { openConfigDialog } from "../core/actions/widget";
+import "./WidgetChrome.css";
+
+
+class WidgetChrome extends React.PureComponent {
+
+  propTypes: {
+    widget: PropTypes.object.isRequired,
+    editMode: PropTypes.bool,
+  }
+  defaultProps: {
+    editMode: false,
+  }
+
+  constructor(props) {
+    super(props);
+    this.handleEditButtonClicked = this.handleEditButtonClicked.bind(this)
+  }
+
+  handleEditButtonClicked(e) {
+    e.preventDefault();
+    this.props.onEdit(this.props.widget.idx);
+  }
+
+  render() {
+    const title = (this.props.editMode && !this.props.widget.title) ? this.props.widget.label : this.props.widget.title;
+    let cssClass = `WidgetChrome card widget-${this.props.widget.name}`;
+    if (this.props.widget.cssClass) {
+      cssClass += " " + this.props.widget.cssClass;
+    }
+    return (
+      <div className={cssClass}>
+        <div className="widget-buttons">
+          { this.props.editMode &&
+            <a href="#"
+              onClick={this.handleEditButtonClicked}
+              >
+              <i className="fa fa-cog" aria-hidden="true"></i>
+            </a>
+          }
+        </div>
+        { title ?
+          <div className="card-header">
+            {title}
+          </div>
+          : null
+        }
+        <div className="card-block">
+          { this.props.editMode &&
+            <div className="text-muted">Content hidden in edit mode</div>
+            ||
+            this.props.children
+          }
+        </div>
+        { this.props.widget.footer ?
+          <div className="card-footer">
+            {this.props.widget.footer}
+          </div>
+          : null
+        }
+      </div>
+    );
+  }
+}
+
+
+const mapStateToProps = (state) => {
+  return {
+    editMode: state.ui.widgetsEditMode,
+  }
+};
+const mapDispatchToProps = dispatch => {
+  return {
+    onEdit: (widgetId) => { dispatch(openConfigDialog(widgetId)); }
+  }
+}
+
+export default connect(
+  mapStateToProps, mapDispatchToProps
+  )(WidgetChrome);
diff --git a/hubs/static/client/app/components/WidgetConfigDialog.js b/hubs/static/client/app/components/WidgetConfigDialog.js
new file mode 100644
index 0000000..1548956
--- /dev/null
+++ b/hubs/static/client/app/components/WidgetConfigDialog.js
@@ -0,0 +1,145 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import {
+  saveConfig,
+  deleteWidget,
+  closeConfigDialog,
+  } from "../core/actions/widget";
+import {
+  makeLoadable,
+  makeLoadingComponent
+} from '../core/utils';
+import SimpleWidgetConfig from '../components/SimpleWidgetConfig';
+import Modal from '../components/Modal';
+
+
+class WidgetConfigDialog extends React.Component {
+
+  propTypes: {
+    widget: PropTypes.object.isRequired,
+    showVisibilitySelect: PropTypes.bool,
+  }
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      widgetConfig: null,
+    };
+    this.handleClose = this.handleClose.bind(this);
+    this.handleWidgetEdited = this.handleWidgetEdited.bind(this);
+    this.handleWidgetDeleted = this.handleWidgetDeleted.bind(this);
+    this.setWidgetConfig = this.setWidgetConfig.bind(this);
+  }
+
+  componentWillReceiveProps(nextProps){
+    if (nextProps.widget.config && (
+        !this.state.widgetConfig ||
+        this.props.widget.idx !== nextProps.widget.idx
+      )) {
+      // Preload the config on initial load and on widget change
+      this.setState({widgetConfig: nextProps.widget.config});
+    }
+  }
+
+  setWidgetConfig(name, value) {
+    this.setState((prevState, props) => {
+      prevState.widgetConfig[name] = value;
+      return {widgetConfig: prevState.widgetConfig};
+    });
+  }
+
+  handleWidgetEdited(e) {
+    e.preventDefault();
+    this.props.dispatch(saveConfig(this.props.widget.idx, this.state.widgetConfig));
+  }
+
+  handleWidgetDeleted(e) {
+    e.preventDefault();
+    this.props.dispatch(deleteWidget(this.props.widget.idx));
+  }
+
+  handleClose(e) {
+    this.props.dispatch(closeConfigDialog());
+  }
+
+  render() {
+    if (!this.props.widget) {
+      return null;
+    }
+    const widgetName = this.props.widget.name;
+    let contents;
+    if (this.props.widget.isReact) {
+      const AsyncComponent = makeLoadable(
+        () => import(`../widgets/${widgetName}/Config`),
+        makeLoadingComponent(
+          "Loading widget configuration...",
+          "Sorry, there was a problem loading the widget configuration."
+        )
+      );
+      contents = (
+        <AsyncComponent
+          widget={this.props.widget}
+          values={this.state.widgetConfig}
+          setConfig={this.setWidgetConfig}
+          />
+      );
+    } else {
+      contents = (
+        <SimpleWidgetConfig
+          widget={this.props.widget}
+          values={this.state.widgetConfig}
+          setConfig={this.setWidgetConfig}
+          />
+      );
+    }
+    const footer = (
+      <div className="d-flex justify-content-end w-100">
+        <button
+          type="button"
+          className="btn btn-danger mr-auto"
+          onClick={this.handleWidgetDeleted}
+          >
+          Remove this widget
+        </button>
+        <button
+          type="button"
+          className="btn btn-secondary ml-3"
+          onClick={this.handleClose}
+          >
+          Close
+        </button>
+        <button
+          type="button"
+          className="btn btn-primary ml-3"
+          onClick={this.handleWidgetEdited}
+          >
+          Save
+        </button>
+      </div>
+    );
+
+    return (
+      <Modal
+        onCloseClicked={this.handleClose}
+        isLarge={this.props.widget.isReact}
+        title={`Configuring widget ${this.props.widget.label}`}
+        footer={footer}
+        >
+        {contents}
+      </Modal>
+    );
+  }
+}
+
+
+const mapStateToProps = (state) => {
+  const confWidgetId = state.ui.widgetConfigDialogOpen;
+  return {
+    widget: (
+      confWidgetId && state.entities.widgets.byId[confWidgetId]
+    ),
+  }
+};
+
+export default connect(mapStateToProps)(WidgetConfigDialog);
diff --git a/hubs/static/client/app/components/WidgetsArea.css b/hubs/static/client/app/components/WidgetsArea.css
new file mode 100644
index 0000000..2cd1368
--- /dev/null
+++ b/hubs/static/client/app/components/WidgetsArea.css
@@ -0,0 +1,5 @@
+.WidgetsArea > .Spinner {
+    position: absolute;
+    left: 1rem;
+    z-index: 1;
+}
diff --git a/hubs/static/client/app/components/WidgetsArea.js b/hubs/static/client/app/components/WidgetsArea.js
new file mode 100644
index 0000000..a23e3df
--- /dev/null
+++ b/hubs/static/client/app/components/WidgetsArea.js
@@ -0,0 +1,90 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import WidgetsColumn from './WidgetsColumn';
+import CobWeb from './CobWeb';
+import WidgetConfigDialog from './WidgetConfigDialog';
+import SSESource from "./SSESource";
+import NotifIcon from "./NotifIcon";
+import Spinner from "./Spinner";
+import { fetchWidgets } from '../core/actions/widgets';
+import "./WidgetsArea.css";
+
+
+class WidgetsArea extends React.PureComponent {
+
+  componentDidMount() {
+    this.props.dispatch(fetchWidgets());
+  }
+
+  render() {
+    if (!this.props.hub.name) {
+      return null;
+    }
+    return (
+      <div className="WidgetsArea">
+        { this.props.widgets.isLoading &&
+          <Spinner />
+        }
+        <div className="row">
+          <div className={`col-md-${this.props.hub.config.left_width}`}>
+
+            { /*
+              First, before the proper widgets.. a cobweb built-in widget.
+              https://pagure.io/fedora-hubs/issue/133
+            */
+              !this.props.editMode &&
+              <CobWeb
+                cause={this.props.hub.config.archived ? "archived" : "old"}
+                mtime={this.props.hub.mtime}
+                />
+            }
+
+            <WidgetsColumn
+              position="left"
+              widgets={
+                this.props.widgets.left.map((widgetIdx) => (
+                  this.props.widgets.byId[widgetIdx]
+                ))
+              }
+              handleWidgetMoved={this.handleWidgetMoved}
+              />
+          </div>
+
+          <div className={`col-md-${this.props.hub.config.right_width}`}>
+            <WidgetsColumn
+              position="right"
+              widgets={
+                this.props.widgets.right.map((widgetIdx) => (
+                  this.props.widgets.byId[widgetIdx]
+                ))
+              }
+              handleWidgetMoved={this.handleWidgetMoved}
+              />
+          </div>
+        </div>
+
+        <WidgetConfigDialog />
+        <SSESource />
+        <NotifIcon />
+      </div>
+    );
+  }
+}
+WidgetsArea.propTypes = {
+  hub: PropTypes.object.isRequired,
+  widgets: PropTypes.object.isRequired,
+  editMode: PropTypes.bool,
+}
+
+
+const mapStateToProps = (state) => {
+  const confWidgetId = state.ui.widgetConfigDialogOpen;
+  return {
+    hub: state.entities.hub,
+    widgets: state.entities.widgets,
+    editMode: state.ui.widgetsEditMode,
+  }
+};
+
+export default connect(mapStateToProps)(WidgetsArea);
diff --git a/hubs/static/client/app/components/WidgetsColumn.js b/hubs/static/client/app/components/WidgetsColumn.js
new file mode 100644
index 0000000..7a4b25c
--- /dev/null
+++ b/hubs/static/client/app/components/WidgetsColumn.js
@@ -0,0 +1,59 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { moveWidget } from '../core/actions/widget';
+import WidgetsList from './WidgetsList';
+import AddWidget from './AddWidget';
+
+
+class WidgetsColumn extends React.Component {
+
+  propTypes: {
+    position: PropTypes.string.isRequired,
+    widgets: PropTypes.array.isRequired,
+    editMode: PropTypes.bool,
+  }
+
+  constructor(props) {
+    super(props);
+    this.handleWidgetMoved = this.handleWidgetMoved.bind(this);
+  }
+
+  handleWidgetMoved({oldIndex, newIndex}, e) {
+    if (oldIndex === newIndex) {
+      return;
+    }
+    this.props.dispatch(moveWidget(this.props.position, oldIndex, newIndex));
+  }
+
+  render() {
+    return (
+      <div className="WidgetsColumn">
+
+        {this.props.editMode &&
+          <AddWidget
+            position={this.props.position}
+            availableWidgetsUrl={this.props.availableWidgetsUrl}
+            />
+        }
+
+        <div id={`${this.props.position}_widgets`}>
+          <WidgetsList
+            widgets={this.props.widgets}
+            handleWidgetMoved={this.handleWidgetMoved}
+            />
+        </div>
+
+      </div>
+    );
+  }
+}
+
+
+const mapStateToProps = (state) => {
+  return {
+    editMode: state.ui.widgetsEditMode,
+  }
+};
+
+export default connect(mapStateToProps)(WidgetsColumn);
diff --git a/hubs/static/client/app/components/WidgetsList.js b/hubs/static/client/app/components/WidgetsList.js
new file mode 100644
index 0000000..137b120
--- /dev/null
+++ b/hubs/static/client/app/components/WidgetsList.js
@@ -0,0 +1,69 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import {SortableContainer, SortableElement} from 'react-sortable-hoc';
+import Widget from './Widget';
+
+
+class WidgetsList extends React.Component {
+  render() {
+    const WidgetComponent = this.props.component;
+    return (
+      <div className="Widgets">
+        {this.props.widgets.map((widget, index) => (
+          <WidgetComponent
+            key={widget.idx}
+            index={index}
+            widget={widget}
+            />
+        ))}
+      </div>
+    );
+  }
+}
+WidgetsList.propTypes = {
+  widgets: PropTypes.array.isRequired,
+  editMode: PropTypes.bool,
+  component: PropTypes.func,
+}
+WidgetsList.defaultProps = {
+  component: Widget,
+  editMode: false,
+}
+
+
+const SortableWidget = SortableElement(Widget);
+const SortableWidgetList = SortableContainer((props) => (
+  <WidgetsList component={SortableWidget} {...props} />
+));
+
+
+function EditableWidgetsList(props) {
+  if (props.editMode) {
+    return (
+      <SortableWidgetList
+        lockAxis="y"
+        onSortEnd={props.handleWidgetMoved}
+        helperClass="widget--dragged"
+        {...props}
+        />
+      );
+  } else {
+    return (<WidgetsList {...props} />);
+  }
+}
+EditableWidgetsList.propTypes = {
+  editMode: PropTypes.bool,
+}
+EditableWidgetsList.defaultProps = {
+  editMode: false,
+}
+
+
+const mapStateToProps = (state) => {
+  return {
+    editMode: state.ui.widgetsEditMode,
+  }
+};
+
+export default connect(mapStateToProps)(EditableWidgetsList);
diff --git a/hubs/static/client/app/components/feed/Actions.js b/hubs/static/client/app/components/feed/Actions.js
new file mode 100644
index 0000000..7219821
--- /dev/null
+++ b/hubs/static/client/app/components/feed/Actions.js
@@ -0,0 +1,36 @@
+import React from 'react';
+
+
+export default class Actions extends React.Component {
+
+  render() {
+    var buttonProps = {
+      id: `save-${this.props.item.dom_id}`,
+      className: "btn btn-sm ",
+    };
+    var buttonText;
+
+    if (this.props.item.saved) {
+      buttonProps.className += "btn-success";
+      buttonProps.onClick = () => {
+        this.props.handleUnsave(this.props.item);
+      };
+      buttonText = "Saved";
+    } else {
+      buttonProps.className += "btn-primary";
+      buttonProps.onClick = () => {
+        this.props.handleSave(this.props.item);
+      };
+      buttonText = "Save";
+    }
+
+    return (
+      <div className="float-right">
+        <button {...buttonProps}>
+          {buttonText}
+        </button>
+      </div>
+    );
+  }
+
+}
diff --git a/hubs/static/client/app/components/feed/Actions.jsx b/hubs/static/client/app/components/feed/Actions.jsx
deleted file mode 100644
index 69890f1..0000000
--- a/hubs/static/client/app/components/feed/Actions.jsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import React from 'react';
-
-
-export default class Actions extends React.Component {
-
-  render() {
-    var buttonProps = {
-      id: `save-${this.props.item.dom_id}`,
-      className: "btn btn-sm ",
-    };
-    var buttonText;
-
-    if (this.props.item.saved) {
-      buttonProps.className += "btn-success";
-      buttonProps.onClick = () => {
-        this.props.handleUnsave(this.props.item);
-      };
-      buttonText = "Saved";
-    } else {
-      buttonProps.className += "btn-primary";
-      buttonProps.onClick = () => {
-        this.props.handleSave(this.props.item);
-      };
-      buttonText = "Save";
-    }
-
-    return (
-      <div className="float-right">
-        <button {...buttonProps}>
-          {buttonText}
-        </button>
-      </div>
-    );
-  }
-
-}
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/components/feed/Feed.css b/hubs/static/client/app/components/feed/Feed.css
new file mode 100644
index 0000000..2286faa
--- /dev/null
+++ b/hubs/static/client/app/components/feed/Feed.css
@@ -0,0 +1,11 @@
+.ItemsGetter .Spinner {
+    margin: 0 auto;
+}
+
+.Feed .media {
+  border-bottom: 1px solid rgba(0, 0, 0, .125);
+}
+
+.Feed .media:last-child {
+  border: 0
+}
diff --git a/hubs/static/client/app/components/feed/Feed.js b/hubs/static/client/app/components/feed/Feed.js
new file mode 100644
index 0000000..338eb2f
--- /dev/null
+++ b/hubs/static/client/app/components/feed/Feed.js
@@ -0,0 +1,47 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+  defineMessages,
+  FormattedMessage,
+  } from 'react-intl';
+import Panel from './Panel';
+import "./Feed.css";
+
+
+const messages = defineMessages({
+  no_items: {
+    id: "hubs.components.feed.no_items",
+    defaultMessage: "No items yet.",
+  },
+});
+
+
+export default class Feed extends React.Component {
+
+  render() {
+    var items = this.props.items || [];
+    items = items.map((item, idx) => {
+      return (
+        <Panel item={item} {...this.props} key={item.dom_id} />
+        );
+    });
+    return (
+      <div className="Feed">
+        { (items.length == 0 && this.props.loaded) ?
+          <FormattedMessage {...messages.no_items} tagName="i" />
+          :
+          items
+        }
+      </div>
+    );
+  }
+
+}
+
+
+Feed.propTypes = {
+  loaded: PropTypes.bool,
+};
+Feed.defaultProps = {
+  loaded: true,
+};
diff --git a/hubs/static/client/app/components/feed/Feed.jsx b/hubs/static/client/app/components/feed/Feed.jsx
deleted file mode 100644
index a11176e..0000000
--- a/hubs/static/client/app/components/feed/Feed.jsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import {
-  defineMessages,
-  FormattedMessage,
-  } from 'react-intl';
-import Panel from './Panel.jsx';
-
-
-const messages = defineMessages({
-  no_items: {
-    id: "hubs.components.feed.no_items",
-    defaultMessage: "No items yet.",
-  },
-});
-
-
-export default class Feed extends React.Component {
-
-  render() {
-    var items = this.props.items || [];
-    items = items.map((item, idx) => {
-      return (
-        <Panel item={item} {...this.props} key={item.dom_id} />
-        );
-    });
-    return (
-      <div className="component-feed">
-        { (items.length == 0 && this.props.loaded) ?
-          <FormattedMessage {...messages.no_items} tagName="i" />
-          :
-          items
-        }
-      </div>
-    );
-  }
-
-}
-
-
-Feed.propTypes = {
-  loaded: PropTypes.bool,
-};
-Feed.defaultProps = {
-  loaded: true,
-};
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/components/feed/Icon.js b/hubs/static/client/app/components/feed/Icon.js
new file mode 100644
index 0000000..4ae2f25
--- /dev/null
+++ b/hubs/static/client/app/components/feed/Icon.js
@@ -0,0 +1,18 @@
+import React from 'react';
+
+export default class Icon extends React.Component {
+  render() {
+    return (
+      <a href={this.props.item.link ? this.props.item.link : '#'}
+         target="_blank"
+         className="d-flex align-self-start mr-3"
+         >
+        <img
+          alt="User avatar"
+          className="square-32 img-circle"
+          src={this.props.item.secondary_icon}
+        />
+      </a>
+    );
+  }
+}
diff --git a/hubs/static/client/app/components/feed/Icon.jsx b/hubs/static/client/app/components/feed/Icon.jsx
deleted file mode 100644
index 9e0cfbe..0000000
--- a/hubs/static/client/app/components/feed/Icon.jsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import React from 'react';
-
-export default class Icon extends React.Component {
-  render() {
-    return (
-      <a href={this.props.item.link ? this.props.item.link : '#'}
-         target="_blank"
-         className="d-flex align-self-start mr-3"
-         >
-        <img
-          alt="User avatar"
-          className="square-32 img-circle"
-          src={this.props.item.secondary_icon}
-        />
-      </a>
-    );
-  }
-}
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/components/feed/ItemsGetter.js b/hubs/static/client/app/components/feed/ItemsGetter.js
new file mode 100644
index 0000000..ff0fb0e
--- /dev/null
+++ b/hubs/static/client/app/components/feed/ItemsGetter.js
@@ -0,0 +1,121 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import Spinner from "../Spinner";
+
+
+class ItemsGetter extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      isLoading: false,
+      errorMessage: null,
+    };
+    this.loadFromServer = this.loadFromServer.bind(this);
+    this.showErrorMessage = this.showErrorMessage.bind(this);
+    this.errorTimeout = null;
+  }
+
+  componentDidMount() {
+    this.loadFromServer();
+  }
+
+  componentWillUnmount() {
+    this.serverRequest.abort();
+  }
+
+  componentDidUpdate(prevProps, prevState) {
+    if (this.props.needsUpdate && !this.state.isLoading) {
+      this.loadFromServer();
+    }
+    if (this.props.useSSE) {
+      if (this.props.sseConnected && this.state.errorMessage) {
+        // Reconnected
+        if (this.errorTimeout) {
+          window.clearTimeout(this.errorTimeout);
+        }
+        this.setState({errorMessage: null});
+      } else if (!this.state.errorMessage && !this.props.sseConnected) {
+        // Disconnected
+        this.errorTimeout = window.setTimeout(this.showErrorMessage, 3000);
+      }
+    }
+  }
+
+  loadFromServer() {
+    if (this.props.onRequestStart) {
+      this.props.onRequestStart();
+    }
+    this.setState({isLoading: true});
+    if (this.serverRequest &&
+        this.serverRequest.readyState !== XMLHttpRequest.DONE) {
+      this.serverRequest.abort();
+    }
+    this.serverRequest = $.ajax({
+      url: this.props.url,
+      method: 'GET',
+      dataType: 'json',
+      cache: false,
+      success: (data, textStatus, jqXHR) => {
+        this.props.handleData(data.data);
+      },
+      error: (xhr, status, err) => {
+        console.error(status, err.toString());
+      },
+      complete: (xhr, status) => {
+        this.setState({isLoading: false});
+      },
+    });
+  }
+
+  showErrorMessage() {
+    let msg = null;
+    switch (this.props.sseReadyState) {
+      case 2:
+        msg = (
+          "Cannot auto-update the feed, you will have to refresh " +
+          "the page manually to see new elements."
+        );
+      case 0:
+        msg = (
+          "Reconnecting to the auto-update source..."
+        );
+    }
+    this.setState({errorMessage: msg});
+  }
+
+  render() {
+    return (
+      <div className="ItemsGetter">
+        {this.props.children}
+        { this.state.isLoading &&
+          <Spinner />
+        }
+        { this.state.errorMessage &&
+          <div className="alert alert-warning mt-3 mb-0">
+            {this.state.errorMessage}
+          </div>
+        }
+      </div>
+    );
+  }
+}
+ItemsGetter.propTypes = {
+  useSSE: PropTypes.bool,
+  needsUpdate: PropTypes.bool,
+}
+ItemsGetter.defaultProps = {
+  useSSE: false,
+  needsUpdate: false,
+}
+
+
+const mapStateToProps = (state) => {
+  return {
+    sseConnected: state.sse.connected,
+    sseReadyState: state.sse.readyState,
+  }
+};
+
+export default connect(mapStateToProps)(ItemsGetter);
diff --git a/hubs/static/client/app/components/feed/ItemsGetter.jsx b/hubs/static/client/app/components/feed/ItemsGetter.jsx
deleted file mode 100644
index 678f420..0000000
--- a/hubs/static/client/app/components/feed/ItemsGetter.jsx
+++ /dev/null
@@ -1,132 +0,0 @@
-import React from 'react';
-
-
-export default class ItemsGetter extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.state = {
-      sseError: false,
-      loading: false,
-    };
-    this.sseSource = null;
-    this.setupSSESource = this.setupSSESource.bind(this);
-    this.tearDownSSESource = this.tearDownSSESource.bind(this);
-    this.handleSSEEvent = this.handleSSEEvent.bind(this);
-    this.handleSSEEventError = this.handleSSEEventError.bind(this);
-    this.handleSSEEventOpen = this.handleSSEEventOpen.bind(this);
-    this.loadFromServer = this.loadFromServer.bind(this);
-  }
-
-  componentDidMount() {
-    this.setupSSESource();
-    this.loadFromServer();
-  }
-
-  componentWillUnmount() {
-    this.serverRequest.abort();
-    this.tearDownSSESource();
-    window.removeEventListener("beforeunload", this.tearDownSSESource);
-  }
-
-  setupSSESource() {
-    if (!this.props.sse ||
-        !this.props.sse.eventName ||
-        !this.props.sse.shouldReload) {
-      // Auto-update is disabled.
-      return;
-    }
-
-    this.sseSource = document.sseSource;
-    if (!this.sseSource || this.sseSource.readyState === EventSource.CLOSED) {
-      this.handleSSEEventError();
-      return;
-    }
-
-    this.sseSource.addEventListener('open', this.handleSSEEventOpen);
-    this.sseSource.addEventListener('error', this.handleSSEEventError);
-    this.sseSource.addEventListener(
-      this.props.sse.eventName, this.handleSSEEvent);
-    // This is necessary to avoid the error message being displayed right
-    // before the user changes page.
-    // Pretty much like: https://bugzilla.mozilla.org/show_bug.cgi?id=833462
-    window.addEventListener("beforeunload", this.tearDownSSESource);
-  }
-
-  tearDownSSESource() {
-    if (!this.sseSource) { return; }
-    this.sseSource.removeEventListener(
-      this.props.sse.eventName, this.handleSSEEvent);
-    this.sseSource.removeEventListener('error', this.handleSSEEventError);
-    this.sseSource = null;
-  }
-
-  handleSSEEvent(e) {
-    if (this.props.sse.shouldReload(e.data)) {
-      this.loadFromServer();
-    }
-  }
-
-  handleSSEEventOpen(e) {
-    this.setState({sseError: false});
-  }
-
-  handleSSEEventError(e) {
-    if (this.sseSource.readyState === 2) {
-      // Connection closed.
-      this.setState({
-        sseError: ("Cannot auto-update the feed, you will have to refresh "
-                  +"the page manually to see new elements.")
-      });
-    } else if (this.sseSource.readyState === 0) {
-      // Reconnecting
-      this.setState({
-        sseError: "Reconnecting to the auto-update source...",
-      });
-    }
-  }
-
-  loadFromServer() {
-    this.setState({loading: true});
-    if (this.serverRequest &&
-        this.serverRequest.readyState !== XMLHttpRequest.DONE) {
-      this.serverRequest.abort();
-    }
-    this.serverRequest = $.ajax({
-      url: this.props.url,
-      method: 'GET',
-      dataType: 'json',
-      cache: false,
-      success: (data, textStatus, jqXHR) => {
-        this.props.handleData(data.data);
-      },
-      error: (xhr, status, err) => {
-        console.error(status, err.toString());
-      },
-      complete: (xhr, status) => {
-        this.setState({loading: false});
-      },
-    });
-  }
-
-  render() {
-    return (
-      <div>
-        {this.props.children}
-        <div
-          className="loading mx-auto"
-          style={{display: this.state.loading ? "block" : "none"}}
-          ></div>
-        { (this.state.sseError !== false) &&
-          <div className="alert alert-warning mt-3">
-            {this.state.sseError}
-          </div>
-        }
-      </div>
-    );
-  }
-
-}
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/components/feed/Markup.js b/hubs/static/client/app/components/feed/Markup.js
new file mode 100644
index 0000000..f5a50f2
--- /dev/null
+++ b/hubs/static/client/app/components/feed/Markup.js
@@ -0,0 +1,50 @@
+import React from 'react';
+import TimeAgo from 'react-timeago';
+
+
+export default class Markup extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.createMarkup = this.createMarkup.bind(this);
+  }
+
+  createMarkup(item) {
+    return { __html: item.markup || item.subtitle };
+  }
+
+  render() {
+    const timestamp = this.props.item.end_time || this.props.item.timestamp;
+    const submsgs = this.props.item.msg_ids;
+    var submessages = [];
+    if (submsgs && Object.keys(submsgs).length > 1) {
+      submessages = Object.keys(submsgs).map((msgid) => {
+        return (
+          <li key={msgid}>
+            <small dangerouslySetInnerHTML={this.createMarkup(submsgs[msgid])}></small>
+          </li>
+        );
+      });
+    }
+
+    return (
+      <div className="media-body align-self-start">
+        <span dangerouslySetInnerHTML={this.createMarkup(this.props.item)}></span>
+        {timestamp &&
+          <small className="text-muted ml-3">
+            <TimeAgo date={timestamp * 1000} />
+          </small>
+        }
+        {submessages.length !== 0 &&
+          <ul
+            className="submessages"
+            style={{display: this.props.detailsOpened ? "block" : "none" }}
+            >
+            {submessages}
+          </ul>
+        }
+      </div>
+    );
+  }
+
+}
diff --git a/hubs/static/client/app/components/feed/Markup.jsx b/hubs/static/client/app/components/feed/Markup.jsx
deleted file mode 100644
index 1ea749e..0000000
--- a/hubs/static/client/app/components/feed/Markup.jsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import React from 'react';
-import TimeAgo from 'react-timeago';
-
-
-export default class Markup extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.createMarkup = this.createMarkup.bind(this);
-  }
-
-  createMarkup(item) {
-    return { __html: item.markup || item.subtitle };
-  }
-
-  render() {
-    const timestamp = this.props.item.end_time || this.props.item.timestamp;
-    const submsgs = this.props.item.msg_ids;
-    var submessages = [];
-    if (submsgs && Object.keys(submsgs).length > 1) {
-      submessages = Object.keys(submsgs).map((msgid) => {
-        return (
-          <li key={msgid}>
-            <small dangerouslySetInnerHTML={this.createMarkup(submsgs[msgid])}></small>
-          </li>
-        );
-      });
-    }
-
-    return (
-      <div className="media-body align-self-start">
-        <span dangerouslySetInnerHTML={this.createMarkup(this.props.item)}></span>
-        {timestamp &&
-          <small className="text-muted ml-3">
-            <TimeAgo date={timestamp * 1000} />
-          </small>
-        }
-        {submessages.length !== 0 &&
-          <ul
-            className="submessages"
-            style={{display: this.props.detailsOpened ? "block" : "none" }}
-            >
-            {submessages}
-          </ul>
-        }
-      </div>
-    );
-  }
-
-}
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/components/feed/Panel.js b/hubs/static/client/app/components/feed/Panel.js
new file mode 100644
index 0000000..2dcee5b
--- /dev/null
+++ b/hubs/static/client/app/components/feed/Panel.js
@@ -0,0 +1,49 @@
+import React from 'react';
+import Icon from './Icon';
+import Markup from './Markup';
+import Actions from './Actions';
+
+
+export default class Panel extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      detailsOpened: false,
+    }
+    this.toggleDetails = this.toggleDetails.bind(this);
+  }
+
+  toggleDetails(e) {
+    e.preventDefault();
+    this.setState((prevState, props) => (
+      {detailsOpened: !prevState.detailsOpened}
+    ));
+  }
+
+  render() {
+    return (
+      <div className="media py-3" id={this.props.item.id}>
+        <Icon item={this.props.item} />
+        <Markup
+          {...this.props}
+          detailsOpened={this.state.detailsOpened}
+          />
+        <div className="d-flex flex-column align-self-start ml-3 text-right">
+          { (this.props.handleSave || this.props.handleUnsave) &&
+            <Actions {...this.props} />
+          }
+          { (this.props.item.msg_ids &&
+             Object.keys(this.props.item.msg_ids).length > 1) &&
+            <div className="mt-auto">
+              <small>
+                <a href="#" onClick={this.toggleDetails}>details</a>
+              </small>
+            </div>
+          }
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/hubs/static/client/app/components/feed/Panel.jsx b/hubs/static/client/app/components/feed/Panel.jsx
deleted file mode 100644
index 5be00e7..0000000
--- a/hubs/static/client/app/components/feed/Panel.jsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import React from 'react';
-import Icon from './Icon.jsx';
-import Markup from './Markup.jsx';
-import Actions from './Actions.jsx';
-
-
-export default class Panel extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.state = {
-      detailsOpened: false,
-    }
-    this.toggleDetails = this.toggleDetails.bind(this);
-  }
-
-  toggleDetails(e) {
-    e.preventDefault();
-    this.setState((prevState, props) => (
-      {detailsOpened: !prevState.detailsOpened}
-    ));
-  }
-
-  render() {
-    return (
-      <div className="media py-3" id={this.props.item.id}>
-        <Icon item={this.props.item} />
-        <Markup
-          {...this.props}
-          detailsOpened={this.state.detailsOpened}
-          />
-        <div className="d-flex flex-column align-self-start ml-3 text-right">
-          { (this.props.handleSave || this.props.handleUnsave) &&
-            <Actions {...this.props} />
-          }
-          { (this.props.item.msg_ids &&
-             Object.keys(this.props.item.msg_ids).length > 1) &&
-            <div className="mt-auto">
-              <small>
-                <a href="#" onClick={this.toggleDetails}>details</a>
-              </small>
-            </div>
-          }
-        </div>
-      </div>
-    );
-  }
-
-}
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/components/feed/__tests__/Feed.test.js b/hubs/static/client/app/components/feed/__tests__/Feed.test.js
index 9f2e9be..501da7f 100644
--- a/hubs/static/client/app/components/feed/__tests__/Feed.test.js
+++ b/hubs/static/client/app/components/feed/__tests__/Feed.test.js
@@ -1,13 +1,13 @@
 /* eslint-env jasmine, jest */
 
-jest.unmock('../Feed.jsx');
-
 import React from 'react';
 import ReactDOM from 'react-dom';
-import TestUtils from 'react-addons-test-utils';
+import {mount} from 'enzyme';
+
+import Feed from '../Feed';
 
-import Feed from '../Feed.jsx';
-import Panel from '../Panel.jsx';
+jest.mock("../Panel");
+import Panel from '../Panel';
 
 describe('Feed', () => {
   const item = {
@@ -22,13 +22,9 @@ describe('Feed', () => {
   });
 
   it('should create the children', () => {
-    const component = TestUtils.renderIntoDocument(
+    const component = mount(
       <Feed items={items} />
     );
-    const node = ReactDOM.findDOMNode(component);
     expect(Panel.mock.calls.length).toEqual(3);
   });
 });
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/components/feed/__tests__/Icon.test.js b/hubs/static/client/app/components/feed/__tests__/Icon.test.js
index 3cd5bb8..fe1c3cd 100644
--- a/hubs/static/client/app/components/feed/__tests__/Icon.test.js
+++ b/hubs/static/client/app/components/feed/__tests__/Icon.test.js
@@ -1,12 +1,9 @@
 /* eslint-env jasmine, jest */
 
-jest.unmock('../Icon.jsx');
-
 import React from 'react';
-import ReactDOM from 'react-dom';
-import TestUtils from 'react-addons-test-utils';
+import {shallow} from 'enzyme';
 
-import Icon from '../Icon.jsx';
+import Icon from '../Icon';
 
 describe('Icon', () => {
   const item = {
@@ -15,24 +12,17 @@ describe('Icon', () => {
   };
 
   it('has the correct link', () => {
-    const component = TestUtils.renderIntoDocument(
+    const component = shallow(
       <Icon item={item} />
     );
     expect(component).toBeTruthy();
-    const node = ReactDOM.findDOMNode(component);
-    expect(node.getAttribute('href')).toEqual(item.link);
+    expect(component.find("a").prop('href')).toEqual(item.link);
   });
   it('has the correct image source', () => {
-    const component = TestUtils.renderIntoDocument(
+    const component = shallow(
       <Icon item={item} />
     );
     expect(component).toBeTruthy();
-
-    const node = ReactDOM.findDOMNode(component);
-    const image = node.querySelector('img');
-    expect(image.getAttribute('src')).toEqual(item.secondary_icon);
+    expect(component.find("img").prop('src')).toEqual(item.secondary_icon);
   });
 });
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/components/feed/__tests__/Markup.test.js b/hubs/static/client/app/components/feed/__tests__/Markup.test.js
index 6305d51..97d9c93 100644
--- a/hubs/static/client/app/components/feed/__tests__/Markup.test.js
+++ b/hubs/static/client/app/components/feed/__tests__/Markup.test.js
@@ -1,12 +1,9 @@
 /* eslint-env jasmine, jest */
 
-jest.unmock('../Markup.jsx');
-
 import React from 'react';
-import ReactDOM from 'react-dom';
-import TestUtils from 'react-addons-test-utils';
+import {shallow} from 'enzyme';
 
-import Markup from '../Markup.jsx';
+import Markup from '../Markup';
 
 describe('Markup', () => {
   const item = {
@@ -15,19 +12,13 @@ describe('Markup', () => {
   };
 
   it('should set the inner html of the markup', () => {
-    const component = TestUtils.renderIntoDocument(
+    const component = shallow(
       <Markup item={item} />
     );
     expect(component).toBeTruthy();
-
-    const node = ReactDOM.findDOMNode(component);
-    const markup = node.querySelector('span');
-
-    expect(markup.innerHTML).toEqual(item.markup);
+    expect(component.find("span").html()).toEqual(
+      "<span>" + item.markup + "</span>");
   });
 
   // we don't test TimeAgo, the library should have its own tests
 });
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/components/feed/__tests__/Panel.test.js b/hubs/static/client/app/components/feed/__tests__/Panel.test.js
index ff93dbe..abdea82 100644
--- a/hubs/static/client/app/components/feed/__tests__/Panel.test.js
+++ b/hubs/static/client/app/components/feed/__tests__/Panel.test.js
@@ -1,12 +1,9 @@
 /* eslint-env jasmine, jest */
 
-jest.unmock('../Panel.jsx');
-
 import React from 'react';
-import ReactDOM from 'react-dom';
-import TestUtils from 'react-addons-test-utils';
+import {shallow} from 'enzyme';
 
-import Panel from '../Panel.jsx';
+import Panel from '../Panel';
 
 describe('Panel', () => {
   const item = {
@@ -21,29 +18,25 @@ describe('Panel', () => {
   };
 
   it('should render the actions block', () => {
-    const component = TestUtils.renderIntoDocument(
-      <Panel item={item} />
+    let node;
+    const component = shallow(
+      <Panel item={item} ref={(el) => {node = el; }} />
     );
     expect(component).toBeTruthy();
 
-    const node = ReactDOM.findDOMNode(component);
-    const block = node.querySelector('div.ml-3.text-right');
-
-    expect(block).toBeTruthy();
+    //const block = node.querySelector('div.ml-3.text-right');
+    expect(component.find("div.ml-3.text-right")).toBeTruthy();
   });
 
   it('should show the details link', () => {
-    const component = TestUtils.renderIntoDocument(
-      <Panel item={item} />
+    let node;
+    const component = shallow(
+      <Panel item={item} ref={(el) => {node = el; }} />
     );
     expect(component).toBeTruthy();
 
-    const node = ReactDOM.findDOMNode(component);
-    const details = node.querySelector('.text-right small a');
+    //const details = node.querySelector('.text-right small a');
 
-    expect(details).toBeTruthy();
+    expect(component.find(".text-right small a")).toBeTruthy();
   });
 });
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/core/App.js b/hubs/static/client/app/core/App.js
new file mode 100644
index 0000000..6c90b23
--- /dev/null
+++ b/hubs/static/client/app/core/App.js
@@ -0,0 +1,32 @@
+import React from 'react';
+import {
+  connect,
+  Provider
+  } from 'react-redux';
+import { IntlProvider } from 'react-intl';
+import * as Pages from "../core/Pages";
+
+
+const PageContainer = connect(
+  (state) => ({pageName: state.ui.page})
+)(
+  (props) => {
+    if (!Pages[props.pageName]) {
+      console.error("No such page: " + props.pageName + ". Choose one of:", Object.keys(Pages).join(", "));
+      return null;
+    }
+    return React.createElement(Pages[props.pageName], {});
+  }
+);
+
+
+const App = (props) => {
+  return (
+    <Provider store={props.store}>
+      <IntlProvider locale={navigator.language}>
+        <PageContainer />
+      </IntlProvider>
+    </Provider>
+  );
+}
+export default App;
diff --git a/hubs/static/client/app/core/HubConfig.jsx b/hubs/static/client/app/core/HubConfig.jsx
deleted file mode 100644
index 01c079f..0000000
--- a/hubs/static/client/app/core/HubConfig.jsx
+++ /dev/null
@@ -1,250 +0,0 @@
-import React from 'react';
-import {
-  IntlProvider,
-  defineMessages,
-  FormattedMessage,
-  } from 'react-intl';
-import GeneralPanel from './HubConfigPanelGeneral.jsx';
-import UserPanel from './HubConfigPanelUser.jsx';
-import ChatPanel from './HubConfigPanelChat.jsx';
-import NotImplementedPanel from './HubConfigPanelNotImpl.jsx';
-import TabSet from '../components/TabSet.jsx';
-
-
-const messages = defineMessages({
-  title: {
-    id: "hubs.core.config.title",
-    defaultMessage: "Hub settings",
-  },
-  general: {
-    id: "hubs.core.config.general",
-    defaultMessage: "General",
-  },
-  owners: {
-    id: "hubs.core.config.owners",
-    defaultMessage: "Owners",
-  },
-  members: {
-    id: "hubs.core.config.members",
-    defaultMessage: "Members",
-  },
-  chat: {
-    id: "hubs.core.config.chat",
-    defaultMessage: "Chat",
-  },
-  other: {
-    id: "hubs.core.config.other",
-    defaultMessage: "Other",
-  },
-  close: {
-    id: "hubs.core.config.close",
-    defaultMessage: "Close",
-  },
-  save_changes: {
-    id: "hubs.core.config.save_changes",
-    defaultMessage: "Save changes",
-  },
-});
-
-
-
-export default class HubConfig extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.state = {
-      hubConfig: {},
-      generalConfig: {},
-      users: {owner: [], member: []},
-      dirty: false,
-      mustReload: false,  // the settings were changed at least once
-      error: null,
-      loading: false
-    };
-    this.serverRequest = null;
-    this.handleChange = this.handleChange.bind(this);
-    this.handleUserChange = this.handleUserChange.bind(this);
-    this.loadState = this.loadState.bind(this);
-    this.pushState = this.pushState.bind(this);
-  }
-
-  getRequestParams() {
-    return {
-      url: this.props.urls.config,
-      dataType: 'json',
-      cache: false,
-      success: function(data) {
-        if (data.status === "OK") {
-          this.setState({
-            hubConfig: data.result.hubconfig,
-            generalConfig: data.result.general,
-            users: data.result.users,
-            dirty: false,
-            error: null,
-          });
-        } else if (data.status === "ERROR") {
-          this.setState({error: data.message});
-        } else {
-          this.setState({
-            error: "Unknown status: " + data.status
-          });
-        }
-      }.bind(this),
-      error: function(xhr, status, err) {
-        var msg = err.toString();
-        if (!msg) { msg = "Error communicating with the server"; }
-        console.error(this.props.urls.config, status, msg);
-        this.setState({error: msg});
-      }.bind(this),
-      complete: function(xhr, status) {
-        this.setState({loading: false});
-      }.bind(this),
-    }
-  }
-
-  loadState() {
-    var params = this.getRequestParams();
-    params.method = "GET";
-    this.setState({loading: true});
-    this.serverRequest = $.ajax(params);
-  }
-
-  pushState(data, category) {
-    var params = this.getRequestParams();
-    params.method = "POST";
-    params.data = data;
-    params.url += "?category=" + category;
-    this.setState({loading: true});
-    this.serverRequest = $.ajax(params);
-  }
-
-  handleChange(e) {
-    var config = this.state.hubConfig;
-    config[e.target.name] = e.target.value;
-    this.setState({
-      hubConfig: config,
-      dirty: true,
-      mustReload: true,
-    }, function() {
-      window.clearTimeout(this.pushTimer);
-      this.pushTimer = window.setTimeout(
-        this.pushState, 1000, this.state.hubConfig, "config");
-    });
-  }
-
-  handleUserChange(username, role) {
-    var data = {username: username, role: role};
-    this.pushState(data, "role_change");
-  }
-
-  componentDidMount() {
-    this.loadState();
-  }
-
-  componentWillUnmount() {
-    if (this.serverRequest) { this.serverRequest.abort(); }
-    if (this.state.mustReload) {
-      // The configuration was changed but the current display may not
-      // reflect it (for example, the displayed summary may be the old one).
-      // The only way to make the changes visible is to reload the page.
-      window.location.reload(true);
-    }
-  }
-
-  render() {
-    return (
-      <IntlProvider locale={navigator.language}>
-        <div className="modal-dialog modal-lg" role="document">
-          <div className="modal-content">
-              <div className="modal-header">
-                <h5 className="modal-title">
-                  <FormattedMessage {...messages.title} />
-                </h5>
-                <button
-                  type="button"
-                  className="close"
-                  data-dismiss="modal"
-                  aria-label="Close"
-                  disabled={this.state.dirty}
-                  >
-                  <span aria-hidden="true">&times;</span>
-                </button>
-              </div>
-              <div className="modal-body p-0">
-                <TabSet
-                  tabWrapperProps={{className: "row pt-0"}}
-                  tabListClass="flex-md-column justify-content-center justify-content-md-start col-md-3 pt-2 pt-md-3 pb-md-4 pr-md-0 pl-md-2"
-                  tabLinkClass="my-md-2"
-                  tabContentClass="col-md-9 p-3 pb-4"
-                  >
-                  <GeneralPanel
-                    hubConfig={this.state.hubConfig}
-                    generalConfig={this.state.generalConfig}
-                    tabTitle={<FormattedMessage {...messages.general} />}
-                    handleChange={this.handleChange}
-                    loading={this.state.loading}
-                    />
-                  <UserPanel
-                    users={this.state.users.owner}
-                    generalConfig={this.state.generalConfig}
-                    role="owner"
-                    handleChange={this.handleUserChange}
-                    tabTitle={<FormattedMessage {...messages.owners} />}
-                    loading={this.state.loading}
-                    urls={this.props.urls}
-                    />
-                  <UserPanel
-                    users={this.state.users.member}
-                    generalConfig={this.state.generalConfig}
-                    role="member"
-                    handleChange={this.handleUserChange}
-                    tabTitle={<FormattedMessage {...messages.members} />}
-                    loading={this.state.loading}
-                    urls={this.props.urls}
-                    />
-                  <ChatPanel
-                    hubConfig={this.state.hubConfig}
-                    generalConfig={this.state.generalConfig}
-                    tabTitle={<FormattedMessage {...messages.chat} />}
-                    handleChange={this.handleChange}
-                    loading={this.state.loading}
-                    />
-                  <NotImplementedPanel
-                    tabTitle={<FormattedMessage {...messages.other} />}
-                    loading={this.state.loading}
-                    />
-                </TabSet>
-              </div>
-              <div className="modal-footer">
-                { this.state.error &&
-                  <div
-                    className="alert alert-danger my-0 mx-3 py-1 px-2"
-                    role="alert"
-                    >
-                    { this.state.error }
-                  </div>
-                }
-                <span
-                  className="loading"
-                  style={this.state.loading ? {display: "inline-block"} : {display: "none"}}
-                  ></span>
-                <button
-                  type="button"
-                  className="btn btn-secondary"
-                  data-dismiss="modal"
-                  disabled={this.state.dirty}
-                  >
-                  <FormattedMessage {...messages.close} />
-                </button>
-              </div>
-          </div>
-        </div>
-      </IntlProvider>
-    );
-  }
-
-}
-
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/core/HubConfigPanelChat.jsx b/hubs/static/client/app/core/HubConfigPanelChat.jsx
deleted file mode 100644
index e15a141..0000000
--- a/hubs/static/client/app/core/HubConfigPanelChat.jsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import React from 'react';
-import {
-  defineMessages,
-  FormattedMessage,
-  } from 'react-intl';
-
-
-const messages = defineMessages({
-  title: {
-    id: "hubs.core.config.chat.title",
-    defaultMessage: "Chat Settings",
-  },
-  intro_1: {
-    id: "hubs.core.config.chat.intro1",
-    defaultMessage: (
-      "If your team or project has an IRC channel associated with it, you "
-     +"can connect this hub to it via the Hubs chat widget."
-     ),
-  },
-  intro_2: {
-    id: "hubs.core.config.chat.intro2",
-    defaultMessage: "Please indicate your team's IRC channel below:",
-  },
-  channel_name: {
-    id: "hubs.core.config.chat.channel_name",
-    defaultMessage: "Channel name",
-  },
-  example: {
-    id: "hubs.core.config.chat.example",
-    defaultMessage: "Example:",
-  },
-  network: {
-    id: "hubs.core.config.chat.network",
-    defaultMessage: "IRC network",
-  },
-});
-
-
-export default class ChatPanel extends React.Component {
-
-  render() {
-    var networks = [],
-        channel = "#",
-        stillLoading = (
-          typeof this.props.hubConfig.chat_channel === "undefined"
-        );
-
-    if (this.props.hubConfig.chat_channel) {
-      channel = "#" + this.props.hubConfig.chat_channel.replace(/^#*/, "");
-    }
-
-    if (this.props.generalConfig.chat_networks) {
-      networks = this.props.generalConfig.chat_networks.map(
-        function(network, index) {
-          return (
-            <option value={network.domain} key={network.domain}>
-              {network.name} - {network.domain}
-            </option>
-          );
-        }.bind(this)
-      );
-    }
-
-    return (
-      <form onSubmit={(e) => {e.preventDefault();}}>
-        <FormattedMessage {...messages.title} tagName="h3" />
-        <FormattedMessage {...messages.intro_1} tagName="p" />
-        <FormattedMessage {...messages.intro_2} tagName="p" />
-        <div className="form-group">
-          <label htmlFor="hub-settings-chat-channel">
-            <FormattedMessage {...messages.channel_name} />
-          </label>
-          <input
-            type="text" className="form-control" name="chat_channel"
-            id="hub-settings-chat-channel"
-            disabled={stillLoading}
-            onChange={this.props.handleChange}
-            value={channel}
-            />
-          <p className="form-text text-muted">
-            <FormattedMessage {...messages.example} tagName="strong" />
-            <span className="ml-2">#fedora-devel</span>
-          </p>
-        </div>
-        <div className="form-group">
-          <label htmlFor="hub-settings-chat-domain">
-            <FormattedMessage {...messages.network} />
-          </label>
-          <select
-            name="chat_domain" className="form-control"
-            id="hub-settings-chat-domain"
-            disabled={stillLoading}
-            onChange={this.props.handleChange}
-            value={this.props.hubConfig.chat_domain || ""}
-            >
-            {networks}
-          </select>
-        </div>
-      </form>
-    );
-  }
-
-}
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/core/HubConfigPanelGeneral.jsx b/hubs/static/client/app/core/HubConfigPanelGeneral.jsx
deleted file mode 100644
index 15d53ab..0000000
--- a/hubs/static/client/app/core/HubConfigPanelGeneral.jsx
+++ /dev/null
@@ -1,145 +0,0 @@
-import React from 'react';
-import {
-  defineMessages,
-  FormattedMessage,
-  FormattedHTMLMessage,
-  } from 'react-intl';
-
-
-const messages = defineMessages({
-  title: {
-    id: "hubs.core.config.general.title",
-    defaultMessage: "General Settings",
-  },
-  intro: {
-    id: "hubs.core.config.general.intro",
-    defaultMessage: "Change your global hub parameters here.",
-  },
-  summary: {
-    id: "hubs.core.config.general.summary",
-    defaultMessage: "Summary",
-  },
-  summary_help: {
-    id: "hubs.core.config.general.summary_help",
-    defaultMessage: "This text will be displayed at the top of the hub.",
-  },
-  left_width: {
-    id: "hubs.core.config.general.left_width",
-    defaultMessage: "Left width",
-  },
-  left_width_help: {
-    id: "hubs.core.config.general.left_width_help",
-    defaultMessage: (
-      "Change the proportions of you hub's columns by setting the width of "
-     +"the left column (the right column's width will be {right_width})."
-      ),
-  },
-  visibility: {
-    id: "hubs.core.config.general.visibility",
-    defaultMessage: "Visibility",
-  },
-  visibility_help: {
-    id: "hubs.core.config.general.visibility_help",
-    defaultMessage: (
-      "Public: the hub is visible to anyone.<br />"
-     +"Private: the hub is only visible to members.<br />"
-     +"Preview: some widgets can be made visible to logged-in users only."
-      ),
-  },
-  avatar: {
-    id: "hubs.core.config.general.avatar",
-    defaultMessage: "Avatar",
-  },
-  avatar_help: {
-    id: "hubs.core.config.general.avatar_help",
-    defaultMessage: "An URL to you hub's main image or logo."
-  },
-});
-
-
-export default class GeneralPanel extends React.Component {
-
-  render() {
-    var stillLoading = (typeof this.props.hubConfig.summary === "undefined");
-    var visibilities = this.props.generalConfig.hub_visibility || [];
-    return (
-      <form onSubmit={(e) => {e.preventDefault();}}>
-        <FormattedMessage {...messages.title} tagName="h3" />
-        <FormattedMessage {...messages.intro} tagName="p" />
-        <div className="form-group">
-          <label htmlFor="hub-settings-general-summary">
-            <FormattedMessage {...messages.summary} />
-          </label>
-          <input
-            type="text" className="form-control" name="summary"
-            id="hub-settings-general-summary"
-            disabled={stillLoading}
-            onChange={this.props.handleChange}
-            value={this.props.hubConfig.summary || ""}
-            />
-          <p className="form-text text-muted">
-            <FormattedMessage {...messages.summary_help} />
-          </p>
-        </div>
-        <div className="form-group">
-          <label htmlFor="hub-settings-general-leftwidth">
-            <FormattedMessage {...messages.left_width} />
-          </label>
-          <input
-            type="number" className="form-control" name="left_width"
-            id="hub-settings-general-leftwidth"
-            disabled={stillLoading}
-            min="1" max="11"
-            onChange={this.props.handleChange}
-            value={this.props.hubConfig.left_width || ""}
-            />
-          <p className="form-text text-muted">
-            <FormattedMessage
-              {...messages.left_width_help}
-              values={{right_width: 12 - (this.props.hubConfig.left_width || 8)}}
-              />
-          </p>
-        </div>
-        <div className="form-group">
-          <label htmlFor="hub-settings-general-visibility">
-            <FormattedMessage {...messages.visibility} />
-          </label>
-          <select
-            className="form-control" name="visibility"
-            value={this.props.hubConfig.visibility || ""}
-            onChange={this.props.handleChange}
-            disabled={stillLoading}
-            >
-            {visibilities.map(function(value) {
-              return (
-                <option value={value} key={value}>{value}</option>
-                );
-            })}
-          </select>
-          <p className="form-text text-muted">
-            <FormattedHTMLMessage {...messages.visibility_help} />
-          </p>
-        </div>
-        <div className="form-group">
-          <label htmlFor="hub-settings-general-avatar">
-            <FormattedMessage {...messages.avatar} />
-          </label>
-          <input
-            type="text" className="form-control" name="avatar"
-            id="hub-settings-general-avatar"
-            disabled={stillLoading}
-            onChange={this.props.handleChange}
-            value={this.props.hubConfig.avatar || ""}
-            />
-          <p className="form-text text-muted">
-            <FormattedMessage {...messages.avatar_help} />
-          </p>
-        </div>
-      </form>
-    );
-  }
-
-}
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/core/HubConfigPanelNotImpl.jsx b/hubs/static/client/app/core/HubConfigPanelNotImpl.jsx
deleted file mode 100644
index 31803be..0000000
--- a/hubs/static/client/app/core/HubConfigPanelNotImpl.jsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import React from 'react';
-
-
-export default class NotImplementedPanel extends React.Component {
-
-  render() {
-    return (
-      <div>
-        Not implemented yet.
-      </div>
-    );
-  }
-
-}
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/core/HubConfigPanelUser.jsx b/hubs/static/client/app/core/HubConfigPanelUser.jsx
deleted file mode 100644
index 7ef5175..0000000
--- a/hubs/static/client/app/core/HubConfigPanelUser.jsx
+++ /dev/null
@@ -1,186 +0,0 @@
-import React from 'react';
-import {
-  injectIntl,
-  defineMessages,
-  FormattedMessage,
-  } from 'react-intl';
-import CompletionInput from '../components/CompletionInput.jsx';
-
-
-const messages = defineMessages({
-  fullname: {
-    id: "hubs.core.config.users.fullname",
-    defaultMessage: "Full name",
-  },
-  username: {
-    id: "hubs.core.config.users.username",
-    defaultMessage: "Username",
-  },
-  change_role: {
-    id: "hubs.core.config.users.change_role",
-    defaultMessage: "Change role",
-  },
-  na: {
-    id: "hubs.core.config.users.na",
-    defaultMessage: "(N/A)",
-  },
-  remove_user: {
-    id: "hubs.core.config.users.remove_user",
-    defaultMessage: "(remove user)",
-  },
-  add_user: {
-    id: "hubs.core.config.users.add_user",
-    defaultMessage: "Add user...",
-  },
-  add: {
-    id: "hubs.core.config.users.add",
-    defaultMessage: "Add",
-  },
-});
-
-
-export default class UserPanel extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.state = {
-      addUsername: "",
-    };
-    this.handleAdd = this.handleAdd.bind(this);
-    this.handleAddInputChange = this.handleAddInputChange.bind(this);
-    this.handleChange = this.handleChange.bind(this);
-    this.filterSuggestedUsers = this.filterSuggestedUsers.bind(this);
-  }
-
-  handleAdd(e) {
-    e.preventDefault();
-    this.props.handleChange(this.state.addUsername, this.props.role);
-    this.setState({addUsername: ""});
-  }
-
-  handleAddInputChange(e) {
-    this.setState({
-      addUsername: e.target.value,
-    });
-  }
-
-  handleChange(e) {
-    e.preventDefault();
-    this.props.handleChange(e.target.name, e.target.value);
-  }
-
-  filterSuggestedUsers(user) {
-    // Only suggest non-members.
-    return (this.props.users.indexOf(user) === -1);
-  }
-
-  render() {
-    var users = this.props.users.map(function(user) {
-      return (
-        <tr key={user.username}>
-          <td>{user.fullname}</td>
-          <td>{user.username}</td>
-          <td>
-            {user.locked ?
-              <FormattedMessage {...messages.na} />
-            :
-              <select
-                name={user.username}
-                value={user.role}
-                className="form-control"
-                onChange={this.handleChange}
-                disabled={this.props.loading}
-                >
-                {this.props.generalConfig.roles.map(function(role) {
-                  return (
-                    <option value={role} key={role}>{role}</option>
-                    );
-                })}
-                <FormattedMessage {...messages.remove_user}>
-                  {(value) => ( <option value="">{value}</option> )}
-                </FormattedMessage>
-              </select>
-            }
-          </td>
-        </tr>
-        );
-    }.bind(this));
-
-    const IntlCompletionInput = injectIntl(function(props) {
-        return (
-          <CompletionInput
-            inputProps={{
-              name: "username",
-              placeholder: props.intl.formatMessage(messages.add_user),
-            }}
-            url={(
-              props.urls.suggestUsers
-              + "?exclude-role=" + props.role
-            )}
-            onChange={this.handleAddInputChange}
-            />
-            );
-    }.bind(this));
-
-    return (
-      <div>
-        <h3>{this.props.tabTitle}</h3>
-        {(this.props.role == "member") &&
-          <div>
-            <p className="text-muted">
-              [Membership requests will be shown here]
-            </p>
-            <hr />
-          </div>
-        }
-        <form
-          className="form-inline mb-3"
-          onSubmit={this.handleAdd}
-          >
-          <div className="form-group">
-            <FormattedMessage {...messages.add_user}>
-              {(message) => (
-                <CompletionInput
-                  inputProps={{
-                    name: "username",
-                    placeholder: message,
-                    value: this.state.addUsername,
-                  }}
-                  url={(
-                    this.props.urls.suggestUsers
-                    + "?exclude-role=" + this.props.role
-                  )}
-                  onChange={this.handleAddInputChange}
-                  />
-              )}
-            </FormattedMessage>
-            </div>
-          <button
-            className="btn btn-primary ml-3"
-            disabled={this.props.loading}
-            >
-            <FormattedMessage {...messages.add} />
-          </button>
-        </form>
-        {users.length !== 0 &&
-        <table className="table table-hover table-sm">
-          <thead>
-            <tr>
-              <FormattedMessage {...messages.fullname} tagName="th" />
-              <FormattedMessage {...messages.username} tagName="th" />
-              <FormattedMessage {...messages.change_role} tagName="th" />
-            </tr>
-          </thead>
-          <tbody>
-            {users}
-          </tbody>
-        </table>
-        }
-      </div>
-    );
-  }
-
-}
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/core/Hubs.js b/hubs/static/client/app/core/Hubs.js
deleted file mode 100644
index 6f24426..0000000
--- a/hubs/static/client/app/core/Hubs.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import HubConfig from './HubConfig.jsx';
-import Streams from './Streams.jsx';
-import ReconnectingEventSource from "reconnecting-eventsource";
-
-module.exports = {HubConfig, Streams, ReconnectingEventSource}
diff --git a/hubs/static/client/app/core/Pages.js b/hubs/static/client/app/core/Pages.js
new file mode 100644
index 0000000..def36e4
--- /dev/null
+++ b/hubs/static/client/app/core/Pages.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import {
+  makeLoadable,
+  makeLoadingComponent
+} from '../core/utils';
+
+
+const PageLoadingComponent = makeLoadingComponent(
+  "Loading...",
+  "Sorry, there was a problem loading the page."
+);
+
+
+let Hub = makeLoadable(
+  () => import(/* webpackChunkName: "page-hub" */ '../components/HubPage'),
+  PageLoadingComponent
+);
+let Streams = makeLoadable(
+  () => import(/* webpackChunkName: "page-streams" */ '../components/StreamsPage'),
+  PageLoadingComponent
+);
+
+//Hub = require('../components/HubPage').default;
+//Streams = require('../components/StreamsPage').default;
+
+export {Hub, Streams};
diff --git a/hubs/static/client/app/core/Streams.jsx b/hubs/static/client/app/core/Streams.jsx
deleted file mode 100644
index a681951..0000000
--- a/hubs/static/client/app/core/Streams.jsx
+++ /dev/null
@@ -1,167 +0,0 @@
-import React from 'react';
-import {
-  IntlProvider,
-  defineMessages,
-  FormattedMessage,
-  } from 'react-intl';
-import TabSet from '../components/TabSet.jsx';
-import ItemsGetter from '../components/feed/ItemsGetter.jsx';
-import Feed from '../components/feed/Feed.jsx';
-
-
-export default class Streams extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.state = {
-      notifItems: [],
-      savedItems: [],
-    };
-    this.handleStreamData = this.handleStreamData.bind(this);
-    this.handleSavedData = this.handleSavedData.bind(this);
-    this.handleSave = this.handleSave.bind(this);
-    this.handleUnsave = this.handleUnsave.bind(this);
-  }
-
-  handleStreamData(data) {
-    this.setState({notifItems: data});
-  }
-
-  handleSavedData(data) {
-    this.setState({savedItems: data});
-  }
-
-  handleSave(item) {
-    if (!this.props.urls.saved) { return; }
-    const payload = {
-      link: item.link,
-      markup: item.markup,
-      secondary_icon: item.secondary_icon,
-      dom_id: item.dom_id,
-    };
-    $.ajax({
-      type: 'POST',
-      url: this.props.urls.saved,
-      data: JSON.stringify(payload),
-      contentType: 'application/json',
-      success: (data) => {
-        this.setState((prevState, props) => {
-          prevState.savedItems.push(data.data);
-          return {savedItems: prevState.savedItems};
-        });
-      },
-    });
-  }
-
-  handleUnsave(item) {
-    if (!this.props.urls.saved) { return; }
-    var updateSavedItems = (item) => {
-      this.setState((prevState, props) => {
-        var items = prevState.savedItems.filter(
-          (currentItem) => (currentItem.dom_id !== item.dom_id)
-        );
-        return {savedItems: items};
-      });
-    };
-    if (item.idx) {
-      // Already saved
-      $.ajax({
-        type: 'DELETE',
-        url: `${this.props.urls.saved}${item.idx}/`,
-        success: () => { updateSavedItems(item) },
-      });
-    } else {
-      updateSavedItems(item);
-    }
-  }
-
-  render() {
-    // Add saved state.
-    var savedItemsIds = this.state.savedItems.map((item) => {
-      return item.dom_id;
-    });
-    var notifs = this.state.notifItems.map((item) => {
-      item.saved = (savedItemsIds.indexOf(item.dom_id) !== -1);
-      return item;
-    });
-
-    return (
-      <IntlProvider locale={navigator.language}>
-        <TabSet tabListClass="mb-3">
-          <FeedPanel
-            username={this.props.username}
-            tabTitle="My Stream"
-            >
-            <ItemsGetter
-              url={this.props.urls.content}
-              sse={{
-                eventName: "hubs:new-notification",
-                shouldReload: (username) => (username === this.props.username),
-              }}
-              handleData={this.handleStreamData}
-              >
-              <Feed
-                items={notifs}
-                handleSave={this.handleSave}
-                handleUnsave={this.handleUnsave}
-                />
-            </ItemsGetter>
-          </FeedPanel>
-          <FeedPanel
-            username={this.props.username}
-            tabTitle="My Actions"
-            >
-            <Feed
-              items={this.state.notifItems}
-              />
-          </FeedPanel>
-          <FeedPanel
-            username={this.props.username}
-            tabTitle="Saved Notifications"
-            >
-            <ItemsGetter
-              url={this.props.urls.saved}
-              handleData={this.handleSavedData}
-              >
-              <Feed
-                items={this.state.savedItems}
-                handleUnsave={this.handleUnsave}
-                />
-            </ItemsGetter>
-          </FeedPanel>
-        </TabSet>
-      </IntlProvider>
-    );
-  }
-}
-
-
-class FeedPanel extends React.Component {
-
-  render() {
-    const filters_url = `https://apps.fedoraproject.org/notifications/${this.props.username}.id.fedoraproject.org/`;
-
-    return (
-      <div>
-        <div className="input-group">
-          <input
-            type="search" className="form-control"
-            placeholder="Search this activity stream..."
-            aria-describedby="searchform-addon" />
-          <span className="input-group-addon">
-            <span><i className="fa fa-search" aria-hidden="true"></i></span>
-          </span>
-        </div>
-        <br/>
-        <div className="alert alert-warning" role="alert">
-          This stream is filtered. <a href={filters_url} target="_blank">View Filters</a>
-        </div>
-        {this.props.children}
-      </div>
-    );
-  }
-
-}
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/core/actions/flashMessages.js b/hubs/static/client/app/core/actions/flashMessages.js
new file mode 100644
index 0000000..2333032
--- /dev/null
+++ b/hubs/static/client/app/core/actions/flashMessages.js
@@ -0,0 +1,19 @@
+/*
+ * Messages
+ */
+
+export const ADD_FLASH_MESSAGE = "ADD_FLASH_MESSAGE";
+export function addFlashMessage(text, msgType) {
+  return {
+    type: ADD_FLASH_MESSAGE,
+    text, msgType
+  }
+}
+
+export const CLOSE_FLASH_MESSAGE = "CLOSE_FLASH_MESSAGE";
+export function closeFlashMessage(index) {
+  return {
+    type: CLOSE_FLASH_MESSAGE,
+    index
+  }
+}
diff --git a/hubs/static/client/app/core/actions/hub.js b/hubs/static/client/app/core/actions/hub.js
new file mode 100644
index 0000000..6099d2e
--- /dev/null
+++ b/hubs/static/client/app/core/actions/hub.js
@@ -0,0 +1,81 @@
+import { apiCall } from '../utils';
+import { addFlashMessage } from './flashMessages';
+
+
+/* GET */
+
+export const HUB_FETCH_REQUEST = 'HUB_FETCH_REQUEST';
+export const HUB_FETCH_SUCCESS = 'HUB_FETCH_SUCCESS';
+export const HUB_FETCH_FAILURE = 'HUB_FETCH_FAILURE';
+
+function requestHub() {
+  return {
+    type: HUB_FETCH_REQUEST,
+  }
+}
+
+function receiveHub(hub) {
+  return {
+    type: HUB_FETCH_SUCCESS,
+    hub: hub,
+    receivedAt: Date.now()
+  }
+}
+
+function hubRequestFailed() {
+  return {
+    type: HUB_FETCH_FAILURE,
+  }
+}
+
+export function fetchHub() {
+  return (dispatch, getState) => {
+    let url = getState().urls.hubConfig;
+    dispatch(requestHub());
+    return apiCall(url).then(
+      hub => dispatch(receiveHub(hub)),
+      error => {
+        dispatch(addFlashMessage(error.message, "error"));
+        return hubRequestFailed();
+      }
+    );
+  }
+}
+
+
+/* PUT */
+
+export const HUB_PUT_REQUEST = 'HUB_PUT_REQUEST';
+export const HUB_PUT_FAILURE = 'HUB_PUT_FAILURE';
+
+function putHub(config, users) {
+  return {
+    type: HUB_PUT_REQUEST,
+    config, users
+  }
+}
+
+function putHubFailure(message) {
+  return {
+    type: HUB_PUT_FAILURE,
+    message
+  }
+}
+
+
+export function saveHub(config, users) {
+  return (dispatch, getState) => {
+    let url = getState().urls.hubConfig;
+    dispatch(putHub(config, users));
+    const body = JSON.stringify({config, users});
+    return apiCall(url, {method: "PUT", body}).then(
+      result => {
+        dispatch(addFlashMessage("Configuration updated", "success"));
+        return dispatch(fetchHub());
+      },
+      error => {
+        dispatch(addFlashMessage(error.message, "error"));
+        return putHubFailure(error.message);
+      })
+  }
+}
diff --git a/hubs/static/client/app/core/actions/sse.js b/hubs/static/client/app/core/actions/sse.js
new file mode 100644
index 0000000..6fc4a66
--- /dev/null
+++ b/hubs/static/client/app/core/actions/sse.js
@@ -0,0 +1,40 @@
+export const SSE_CONNECTED = 'SSE_CONNECTED';
+export const SSE_DISCONNECTED = 'SSE_DISCONNECTED';
+
+
+export function sseConnected(source) {
+  return {
+    type: SSE_CONNECTED,
+    readyState: source.readyState
+  };
+}
+
+export function sseDisconnected(source) {
+  return {
+    type: SSE_DISCONNECTED,
+    readyState: source.readyState
+  };
+}
+
+
+export const WIDGET_NEEDS_UPDATE = 'WIDGET_NEEDS_UPDATE';
+export const NEW_NOTIFICATION = 'NEW_NOTIFICATION';
+
+export function sseEvent(type, data) {
+  switch (type) {
+    case "hubs:widget-updated":
+      const widgetId = data;
+      return {
+        type: WIDGET_NEEDS_UPDATE,
+        widgetId,
+      };
+    case "hubs:new-notification":
+      // TODO: implement the reducers for this action
+      const username = data;
+      return {
+        type: NEW_NOTIFICATION,
+        username,
+      };
+  }
+}
+
diff --git a/hubs/static/client/app/core/actions/stream.js b/hubs/static/client/app/core/actions/stream.js
new file mode 100644
index 0000000..a8dc405
--- /dev/null
+++ b/hubs/static/client/app/core/actions/stream.js
@@ -0,0 +1,16 @@
+export const STREAM_WILL_UPDATE = 'STREAM_WILL_UPDATE';
+export const STREAM_DID_UPDATE = 'STREAM_DID_UPDATE';
+
+export function streamWillUpdate() {
+  // TODO: implement the reducers for this action
+  return {
+    type: STREAM_WILL_UPDATE,
+  };
+}
+
+export function streamDidUpdate() {
+  // TODO: implement the reducers for this action
+  return {
+    type: STREAM_DID_UPDATE,
+  };
+}
diff --git a/hubs/static/client/app/core/actions/widget.js b/hubs/static/client/app/core/actions/widget.js
new file mode 100644
index 0000000..e7784a4
--- /dev/null
+++ b/hubs/static/client/app/core/actions/widget.js
@@ -0,0 +1,204 @@
+import { apiCall } from '../utils';
+import { addFlashMessage } from './flashMessages';
+import { fetchWidgets } from "./widgets";
+
+
+/* Update status */
+
+export const WIDGET_WILL_UPDATE = 'WIDGET_WILL_UPDATE'
+export const WIDGET_DID_UPDATE = 'WIDGET_DID_UPDATE'
+
+export function widgetWillUpdate(widgetId) {
+  return {
+    type: WIDGET_WILL_UPDATE,
+    widgetId,
+    receivedAt: Date.now()
+  }
+}
+
+export function widgetDidUpdate(widgetId) {
+  return {
+    type: WIDGET_DID_UPDATE,
+    widgetId,
+    receivedAt: Date.now()
+  }
+}
+
+
+/* PUT config */
+
+export const WIDGET_PUT_REQUEST = 'WIDGET_PUT_REQUEST';
+export const WIDGET_PUT_SUCCESS = 'WIDGET_PUT_SUCCESS';
+export const WIDGET_PUT_FAILURE = 'WIDGET_PUT_FAILURE';
+
+function putConfigRequest(widgetId, config) {
+  return {
+    type: WIDGET_PUT_REQUEST,
+    widgetId, config
+  }
+}
+function putConfigSuccess(widgetId, widgetProps) {
+  return {
+    type: WIDGET_PUT_SUCCESS,
+    widgetId, widgetProps
+  }
+}
+function putConfigSucceeded(widgetId) {
+  return (dispatch, getState) => {
+    const state = getState();
+    let url = state.entities.widgets.byId[widgetId].selfUrl;
+    return apiCall(url).then(
+      (widgetProps) => (dispatch(putConfigSuccess(widgetId, widgetProps))),
+      error => (putConfigFailure(widgetId, error.message))
+    );
+  }
+}
+function putConfigFailure(widgetId, message) {
+  return {
+    type: WIDGET_PUT_FAILURE,
+    widgetId, message
+  }
+}
+
+export function saveConfig(widgetId, config) {
+  return (dispatch, getState) => {
+    let url = getState().entities.widgets.byId[widgetId].selfUrl;
+    dispatch(putConfigRequest(widgetId, config));
+    const body = JSON.stringify({config});
+    return apiCall(url, {method: "PUT", body}).then(
+      result => {
+        dispatch(addFlashMessage("Widget configuration changed.", "success"));
+        return dispatch(putConfigSucceeded(widgetId));
+      },
+      error => {
+        dispatch(addFlashMessage(error.message, "error"));
+        return putConfigFailure(error.message);
+      })
+  }
+}
+
+
+
+/* DELETE */
+
+export const WIDGET_DELETE_SUCCESS = 'WIDGET_DELETE_SUCCESS';
+
+function widgetDeleted(widgetId, config) {
+  return {
+    type: WIDGET_DELETE_SUCCESS,
+    widgetId,
+  }
+}
+
+export function deleteWidget(widgetId) {
+  return (dispatch, getState) => {
+    let url = getState().entities.widgets.byId[widgetId].selfUrl;
+    return apiCall(url, {method: "DELETE"}).then(
+      result => {
+        dispatch(addFlashMessage("Widget removed.", "success"));
+        return dispatch(widgetDeleted(widgetId));
+      },
+      error => {
+        return dispatch(addFlashMessage(error.message, "error"));
+      })
+  }
+}
+
+
+
+/* Create (POST) */
+
+export const HUB_WIDGETS_ADD_REQUEST = 'HUB_WIDGETS_ADD_REQUEST';
+export const HUB_WIDGETS_ADD_FAILURE = 'HUB_WIDGETS_ADD_FAILURE';
+
+function widgetAddRequest(name, config, position) {
+  return {
+    type: HUB_WIDGETS_ADD_REQUEST,
+    name, config, position
+  }
+}
+function widgetAddFailure(name, message) {
+  return {
+    type: HUB_WIDGETS_ADD_FAILURE,
+    name, message
+  }
+}
+
+
+export function addWidget(name, config, position) {
+  return (dispatch, getState) => {
+    const state = getState();
+    let url = state.urls.widgets;
+    dispatch(widgetAddRequest(name, config));
+    const body = JSON.stringify({name, config, position});
+    return apiCall(url, {method: "POST", body}).then(
+      result => {
+        dispatch(addFlashMessage("Widget added.", "success"));
+        return dispatch(fetchWidgets());
+      },
+      error => {
+        dispatch(addFlashMessage(error.message, "error"));
+        return widgetAddFailure(error.message);
+      })
+  }
+}
+
+
+
+/* Move */
+
+export const WIDGET_MOVE_REQUEST = 'WIDGET_MOVE_REQUEST'
+export const WIDGET_MOVE_FAILURE = 'WIDGET_MOVE_FAILURE'
+
+
+function moveWidgetRequest(position, oldIndex, newIndex) {
+  return {
+    type: WIDGET_MOVE_REQUEST,
+    position, oldIndex, newIndex
+  }
+}
+function moveWidgetFailure(position, oldIndex, newIndex, message) {
+  return {
+    type: WIDGET_MOVE_FAILURE,
+    position, oldIndex, newIndex, message
+  }
+}
+
+export function moveWidget(position, oldIndex, newIndex) {
+  return (dispatch, getState) => {
+    const state = getState();
+    const widgetId = state.entities.widgets[position][oldIndex];
+    const widget = state.entities.widgets.byId[widgetId];
+    console.log("Moving widget", widget.name, "at index", oldIndex, "to", newIndex);
+    dispatch(moveWidgetRequest(position, oldIndex, newIndex));
+    const body = JSON.stringify({index: newIndex});
+    return apiCall(widget.selfUrl, {method: "PUT", body}).then(
+      result => {
+        return dispatch(fetchWidgets());
+      },
+      error => {
+        return dispatch(moveWidgetFailure(position, oldIndex, newIndex, e.message));
+      })
+  }
+}
+
+
+
+/* UI */
+
+export const WIDGET_OPEN_CONFIG = 'WIDGET_OPEN_CONFIG';
+export const WIDGET_CLOSE_CONFIG = 'WIDGET_CLOSE_CONFIG';
+
+export function openConfigDialog(widgetId) {
+  return {
+    type: WIDGET_OPEN_CONFIG,
+    widgetId,
+  }
+}
+
+export function closeConfigDialog(widgetId) {
+  return {
+    type: WIDGET_CLOSE_CONFIG,
+    widgetId,
+  }
+}
diff --git a/hubs/static/client/app/core/actions/widgets.js b/hubs/static/client/app/core/actions/widgets.js
new file mode 100644
index 0000000..4a4abaf
--- /dev/null
+++ b/hubs/static/client/app/core/actions/widgets.js
@@ -0,0 +1,104 @@
+import { apiCall } from '../utils';
+import { addFlashMessage } from './flashMessages';
+
+
+/* GET */
+
+export const HUB_WIDGETS_FETCH_REQUEST = 'HUB_WIDGETS_FETCH_REQUEST'
+export const HUB_WIDGETS_FETCH_SUCCESS = 'HUB_WIDGETS_FETCH_SUCCESS'
+export const HUB_WIDGETS_FETCH_FAILURE = 'HUB_WIDGETS_FETCH_FAILURE'
+
+
+function requestWidgets() {
+  return {
+    type: HUB_WIDGETS_FETCH_REQUEST,
+  }
+}
+
+function receiveWidgets(widgets) {
+  return {
+    type: HUB_WIDGETS_FETCH_SUCCESS,
+    widgets: widgets,
+    receivedAt: Date.now()
+  }
+}
+
+function widgetsRequestFailed() {
+  return {
+    type: HUB_WIDGETS_FETCH_FAILURE,
+  }
+}
+
+
+export function fetchWidgets() {
+  return (dispatch, getState) => {
+    let url = getState().urls.widgets;
+    dispatch(requestWidgets());
+    return apiCall(url).then(
+      widgets => dispatch(receiveWidgets(widgets)),
+      error => {
+        dispatch(addFlashMessage(error.message, "error"));
+        return widgetsRequestFailed();
+      }
+    );
+  }
+}
+
+
+
+/* Get list of widgets */
+
+export const HUB_WIDGETS_AVAIL_REQUEST = 'HUB_WIDGETS_AVAIL_REQUEST';
+export const HUB_WIDGETS_AVAIL_SUCCESS = 'HUB_WIDGETS_AVAIL_SUCCESS';
+export const HUB_WIDGETS_AVAIL_FAILURE = 'HUB_WIDGETS_AVAIL_FAILURE';
+
+function widgetsAvailRequest() {
+  return {
+    type: HUB_WIDGETS_AVAIL_REQUEST,
+  }
+}
+function widgetsAvailSuccess(widgets) {
+  return {
+    type: HUB_WIDGETS_AVAIL_SUCCESS,
+    widgets
+  }
+}
+function widgetsAvailFailure(message) {
+  return {
+    type: HUB_WIDGETS_AVAIL_FAILURE,
+    message
+  }
+}
+
+
+export function getAvailableWidgets() {
+  return (dispatch, getState) => {
+    const state = getState();
+    if (
+      state.entities.availableWidgets.left.length > 0 ||
+      state.entities.availableWidgets.right.length > 0) {
+      return Promise.resolve();  // Already in the cache
+    }
+    let url = state.urls.availableWidgets;
+    dispatch(widgetsAvailRequest());
+    return apiCall(url).then(
+      widgets => {
+        return dispatch(widgetsAvailSuccess(widgets));
+      },
+      error => {
+        dispatch(addFlashMessage(error.message, "error"));
+        return widgetsAvailFailure(error.message);
+      })
+  }
+}
+
+
+/* Edit mode */
+
+export const HUB_WIDGETS_EDIT_MODE = 'HUB_WIDGETS_EDIT_MODE'
+export function setEditMode(editMode) {
+  return {
+    type: HUB_WIDGETS_EDIT_MODE,
+    editMode
+  }
+}
diff --git a/hubs/static/client/app/core/index.js b/hubs/static/client/app/core/index.js
new file mode 100644
index 0000000..a6cbb81
--- /dev/null
+++ b/hubs/static/client/app/core/index.js
@@ -0,0 +1,27 @@
+/* Add the ES6 Promise polyfill because all browsers don't have it yet. */
+import 'babel-polyfill';
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { createStore, applyMiddleware } from 'redux';
+import thunk from 'redux-thunk';
+import rootReducer from './reducers';
+import App from "./App";
+
+
+export function setupPage(elementId, initialState) {
+
+  const store = createStore(
+    rootReducer,
+    initialState,
+    applyMiddleware(thunk)
+  );
+  //store.subscribe(() =>
+  //  console.log(store.getState())
+  //)
+  ReactDOM.render(
+    <App store={store} />,
+    document.getElementById(elementId)
+    );
+
+}
diff --git a/hubs/static/client/app/core/reducers/flashMessages.js b/hubs/static/client/app/core/reducers/flashMessages.js
new file mode 100644
index 0000000..ae961a0
--- /dev/null
+++ b/hubs/static/client/app/core/reducers/flashMessages.js
@@ -0,0 +1,24 @@
+import {
+  ADD_FLASH_MESSAGE,
+  CLOSE_FLASH_MESSAGE
+} from '../actions/flashMessages';
+
+
+export default function flashMessages(state=[], action) {
+  switch (action.type) {
+    case ADD_FLASH_MESSAGE:
+      return [
+        ...state,
+        {
+          msg: action.text,
+          type: action.msgType
+        }
+      ];
+    case CLOSE_FLASH_MESSAGE:
+      let stateCopy = state.slice();
+      stateCopy.splice(action.index, 1);
+      return stateCopy;
+    default:
+      return state
+  }
+}
diff --git a/hubs/static/client/app/core/reducers/hub.js b/hubs/static/client/app/core/reducers/hub.js
new file mode 100644
index 0000000..ab788ed
--- /dev/null
+++ b/hubs/static/client/app/core/reducers/hub.js
@@ -0,0 +1,56 @@
+import {
+  HUB_FETCH_REQUEST,
+  HUB_FETCH_SUCCESS,
+  HUB_FETCH_FAILURE,
+  HUB_PUT_REQUEST,
+  HUB_PUT_FAILURE,
+  } from '../actions/hub';
+
+
+export function hubReducer(
+  state={
+    name: null,
+    isLoading: false,
+    old: {},
+  },
+  action
+) {
+  switch (action.type) {
+    case HUB_FETCH_REQUEST:
+      return {
+        ...state,
+        isLoading: true
+      };
+    case HUB_FETCH_SUCCESS:
+      return {
+        ...state,
+        ...action.hub,
+        isLoading: false,
+        old: {}
+      };
+    case HUB_FETCH_FAILURE:
+      return {
+        ...state,
+        isLoading: false
+      };
+    case HUB_PUT_REQUEST:
+      return {
+        ...state,
+        isLoading: true,
+        // Optimistic update
+        config: action.config,
+        users: action.users,
+        old: {config: state.config, users: state.users},
+      };
+    case HUB_PUT_FAILURE:
+      return {
+        ...state,
+        isLoading: false,
+        // Optimism failed... ;-(
+        config: state.old.config,
+        users: state.old.users,
+      };
+    default:
+      return state
+  }
+}
diff --git a/hubs/static/client/app/core/reducers/index.js b/hubs/static/client/app/core/reducers/index.js
new file mode 100644
index 0000000..3d4e9df
--- /dev/null
+++ b/hubs/static/client/app/core/reducers/index.js
@@ -0,0 +1,47 @@
+import { combineReducers } from 'redux';
+import flashMessages from "./flashMessages";
+import sseReducer from "./sse";
+import {
+  hubReducer,
+  hubEditMode
+  } from "./hub";
+import {
+  widgetsReducer,
+  widgetsEditMode,
+  widgetConfigDialogOpen,
+  availableWidgetsReducer,
+  } from "./widgets";
+
+
+const entities = combineReducers({
+  hub: hubReducer,
+  widgets: widgetsReducer,
+  availableWidgets: availableWidgetsReducer,
+});
+
+
+const ui = combineReducers({
+  page: noop,
+  flashMessages,
+  widgetsEditMode,
+  widgetConfigDialogOpen,
+  leftMenu: noop,
+});
+
+
+function noop(state=null, action) {
+  return state;
+}
+
+
+const rootReducer = combineReducers({
+  flashMessages,
+  sse: sseReducer,
+  globalConfig: noop,
+  urls: noop,
+  currentUser: noop,
+  ui,
+  entities,
+});
+
+export default rootReducer;
diff --git a/hubs/static/client/app/core/reducers/sse.js b/hubs/static/client/app/core/reducers/sse.js
new file mode 100644
index 0000000..fb2b7d9
--- /dev/null
+++ b/hubs/static/client/app/core/reducers/sse.js
@@ -0,0 +1,32 @@
+import {
+  SSE_CONNECTED,
+  SSE_DISCONNECTED
+} from '../actions/sse';
+
+
+export default function sseReducer(state={
+  connected: false,
+  readyState: 2
+}, action) {
+  switch (action.type) {
+    case SSE_CONNECTED:
+      if (state.connected) {
+        return state;  // No change
+      }
+      return {
+        connected: true,
+        readyState: action.readyState,
+      };
+    case SSE_DISCONNECTED:
+      if (!state.connected) {
+        return state;  // No change
+      }
+      return {
+        connected: false,
+        readyState: action.readyState,
+      };
+    default:
+      return state
+  }
+}
+
diff --git a/hubs/static/client/app/core/reducers/widget.js b/hubs/static/client/app/core/reducers/widget.js
new file mode 100644
index 0000000..524e024
--- /dev/null
+++ b/hubs/static/client/app/core/reducers/widget.js
@@ -0,0 +1,29 @@
+import {
+  WIDGET_PUT_REQUEST, WIDGET_PUT_SUCCESS, WIDGET_PUT_FAILURE,
+  } from '../actions/widget';
+import { WIDGET_NEEDS_UPDATE } from '../actions/sse';
+
+
+export default function singleWidgetReducer(state, action) {
+  switch (action.type) {
+    case WIDGET_PUT_REQUEST:
+      return {
+        ...state,
+        // Optimistic update
+        config: action.config,
+        old: {config: state.config},
+      };
+    case WIDGET_PUT_SUCCESS:
+      return {
+        ...action.widgetProps,
+      };
+    case WIDGET_PUT_FAILURE:
+      return {
+        ...state,
+        // Optimism failed... ;-(
+        config: state.old.config,
+      };
+    default:
+      return state
+  }
+}
diff --git a/hubs/static/client/app/core/reducers/widgets.js b/hubs/static/client/app/core/reducers/widgets.js
new file mode 100644
index 0000000..014b85c
--- /dev/null
+++ b/hubs/static/client/app/core/reducers/widgets.js
@@ -0,0 +1,185 @@
+import { combineReducers } from 'redux';
+import { arrayMove } from 'react-sortable-hoc';
+import {
+  HUB_WIDGETS_FETCH_REQUEST, HUB_WIDGETS_FETCH_SUCCESS, HUB_WIDGETS_FETCH_FAILURE,
+  HUB_WIDGETS_EDIT_MODE,
+  HUB_WIDGETS_AVAIL_REQUEST, HUB_WIDGETS_AVAIL_SUCCESS, HUB_WIDGETS_AVAIL_FAILURE,
+  } from '../actions/widgets';
+import {
+  WIDGET_PUT_SUCCESS, WIDGET_DELETE_SUCCESS,
+  WIDGET_WILL_UPDATE, WIDGET_DID_UPDATE,
+  HUB_WIDGETS_ADD_REQUEST, HUB_WIDGETS_ADD_FAILURE,
+  WIDGET_MOVE_REQUEST, WIDGET_MOVE_FAILURE,
+  WIDGET_OPEN_CONFIG, WIDGET_CLOSE_CONFIG,
+  } from '../actions/widget';
+import { WIDGET_NEEDS_UPDATE } from '../actions/sse';
+import singleWidgetReducer from './widget';
+
+
+function widgetsAreLoading(state=false, action) {
+  switch (action.type) {
+    case HUB_WIDGETS_FETCH_REQUEST:
+    case HUB_WIDGETS_ADD_REQUEST:
+    case HUB_WIDGETS_AVAIL_REQUEST:
+    case WIDGET_MOVE_REQUEST:
+      return true;
+    case HUB_WIDGETS_FETCH_SUCCESS:
+    case HUB_WIDGETS_FETCH_FAILURE:
+    case HUB_WIDGETS_ADD_FAILURE:
+    case HUB_WIDGETS_AVAIL_SUCCESS:
+    case HUB_WIDGETS_AVAIL_FAILURE:
+    case WIDGET_MOVE_FAILURE:
+      return false;
+    default:
+      return state
+  }
+}
+
+
+function widgetList(position) {
+  return (state=[], action) => {
+    switch (action.type) {
+      case HUB_WIDGETS_FETCH_SUCCESS:
+        return action.widgets.filter(
+          widget => (widget.position === position)
+        ).map(widget => widget.idx);
+      case WIDGET_DELETE_SUCCESS:
+        return state.filter((idx) => (idx != action.widgetId));
+      case WIDGET_MOVE_REQUEST:
+        // Optimistic update
+        if (action.position !== position) {
+          return state;
+        }
+        return arrayMove(state, action.oldIndex, action.newIndex);
+      case WIDGET_MOVE_FAILURE:
+        if (action.position !== position) {
+          return state;
+        }
+        return arrayMove(state, action.newIndex, action.oldIndex);
+      default:
+        return state
+    }
+  }
+}
+
+
+function widgetsNeedUpdate(state=[], action) {
+  switch (action.type) {
+    case HUB_WIDGETS_FETCH_SUCCESS:
+      return action.widgets.map((widget) => (widget.idx));
+    case WIDGET_DELETE_SUCCESS:
+    case WIDGET_WILL_UPDATE:
+      if (state.indexOf(action.widgetId) === -1) {
+        return state;
+      } else {
+        return state.filter((idx) => (idx != action.widgetId));
+      }
+    case WIDGET_PUT_SUCCESS:
+    case WIDGET_NEEDS_UPDATE:
+      return [...state, action.widgetId];
+    default:
+      return state
+  }
+}
+
+
+function widgetsById(state={}, action){
+  let newState;
+  switch (action.type) {
+    case HUB_WIDGETS_FETCH_SUCCESS:
+      newState = {};
+      action.widgets.forEach((widget) => {
+        newState[widget.idx] = widget;
+      });
+      return newState;
+    case WIDGET_DELETE_SUCCESS:
+      newState = Object.assign({}, state);
+      delete newState[action.widgetId];
+      return newState;
+    default:
+      if (!action.widgetId) {
+        return state;
+      }
+      let widget = state[action.widgetId];
+      return {
+        ...state,
+        [action.widgetId]: singleWidgetReducer(widget, action),
+      };
+  }
+}
+
+
+export const widgetsReducer = combineReducers({
+  isLoading: widgetsAreLoading,
+  left: widgetList("left"),
+  right: widgetList("right"),
+  byId: widgetsById,
+  needUpdate: widgetsNeedUpdate,
+});
+
+
+
+/* Available widgets */
+
+
+function availWidgetsList(position) {
+  return (state=[], action) => {
+    switch (action.type) {
+      case HUB_WIDGETS_AVAIL_SUCCESS:
+        return action.widgets.filter(
+          widget => (
+            widget.position === position || widget.position === "both"
+          )
+        ).map(widget => widget.name);
+      default:
+        return state
+    }
+  }
+}
+
+function availWidgetsByName(state={}, action){
+  let newState = {};
+  switch (action.type) {
+    case HUB_WIDGETS_AVAIL_SUCCESS:
+      action.widgets.forEach((widget) => {
+        newState[widget.name] = widget;
+      });
+      return newState;
+    default:
+      return state
+  }
+}
+
+export const availableWidgetsReducer = combineReducers({
+  isLoading: widgetsAreLoading,
+  left: availWidgetsList("left"),
+  right: availWidgetsList("right"),
+  byName: availWidgetsByName,
+});
+
+
+
+/* UI */
+
+export function widgetsEditMode(state=false, action) {
+  switch (action.type) {
+    case HUB_WIDGETS_EDIT_MODE:
+      return action.editMode;
+    default:
+      return state
+  }
+}
+
+
+export function widgetConfigDialogOpen(state=false, action) {
+  switch (action.type) {
+    case WIDGET_OPEN_CONFIG:
+      return action.widgetId;
+    case WIDGET_CLOSE_CONFIG:
+    case WIDGET_PUT_SUCCESS:
+    case WIDGET_DELETE_SUCCESS:
+      return false;
+    default:
+      return state
+  }
+}
diff --git a/hubs/static/client/app/core/utils.js b/hubs/static/client/app/core/utils.js
new file mode 100644
index 0000000..0699a93
--- /dev/null
+++ b/hubs/static/client/app/core/utils.js
@@ -0,0 +1,88 @@
+import React from 'react';
+import fetch from 'isomorphic-fetch';
+import Loadable from 'react-loadable';
+
+
+export function backendCall(url, fetchConfig, json=false) {
+  console.log("Calling backend:", url, fetchConfig);
+  const baseFetchConfig = {
+    credentials: "same-origin",
+    headers: {"Content-Type": "application/json"},
+  }
+  fetchConfig = Object.assign({}, baseFetchConfig, fetchConfig);
+  return fetch(url, {...baseFetchConfig, ...fetchConfig}).then(
+    (response) => {
+      if (response.ok) {
+        return json ? response.json() : response.text();
+      } else {
+        return response.text().then((msg) => {
+          throw new Error(msg);
+        });
+      }
+    }
+  ).catch((error) => {
+    console.error(error.message);
+    throw error;
+  });
+}
+
+
+export function apiCall(url, fetchConfig) {
+  return backendCall(url, fetchConfig, true).then(result => {
+    if (result.status === "OK") {
+      return result.data;
+    } else {
+      throw new Error(result.message);
+    }
+  });
+}
+
+
+export function makeLoadable(loader, loading) {
+  const renderDefault = (loaded, props) => {
+    const Component = loaded.default;
+    return <Component {...props} />;
+  }
+  return Loadable({
+    loader,
+    loading,
+    render: renderDefault,
+    delay: 200,
+  });
+}
+
+export function makeLoadingComponent(loadingText, errorText) {
+  class LoadingComponent extends React.Component {
+    render() {
+      // Handle the loading state
+      if (this.props.isLoading) {
+        if (this.props.timedOut) {
+          // In case we've timed out loading our other component.
+          return <div className="text-warning">Loader timed out!</div>;
+        } else if (this.props.pastDelay) {
+          // Display a loading screen after a set delay.
+          return (
+            <div className="text-muted">
+              {loadingText}
+            </div>
+          );
+        } else {
+          // Don't flash "Loading..." when we don't need to.
+          return null;
+        }
+      }
+      // Handle the error state
+      else if (this.props.error) {
+        return (
+          <div>
+            {errorText}
+          </div>
+        );
+      }
+      else {
+        return null;
+      }
+    }
+  }
+  return LoadingComponent;
+}
diff --git a/hubs/static/client/app/widgets/feed/Config.js b/hubs/static/client/app/widgets/feed/Config.js
new file mode 100644
index 0000000..1fc4fa8
--- /dev/null
+++ b/hubs/static/client/app/widgets/feed/Config.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import SimpleWidgetConfig from '../../components/SimpleWidgetConfig';
+
+
+// Use the default configuration, it's sufficient.
+
+export default function Config(props) {
+  return (
+    <SimpleWidgetConfig {...props} />
+  );
+}
diff --git a/hubs/static/client/app/widgets/feed/Feed.js b/hubs/static/client/app/widgets/feed/Feed.js
deleted file mode 100644
index af0b1c6..0000000
--- a/hubs/static/client/app/widgets/feed/Feed.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import Widget from './Widget.jsx';
-
-
-// Don't use the ES6 "export default" construct:
-// http://stackoverflow.com/questions/40294870/module-exports-vs-export-default-in-node-js-and-es6
-
-module.exports = {Widget};
diff --git a/hubs/static/client/app/widgets/feed/Widget.js b/hubs/static/client/app/widgets/feed/Widget.js
new file mode 100644
index 0000000..31e6f6f
--- /dev/null
+++ b/hubs/static/client/app/widgets/feed/Widget.js
@@ -0,0 +1,66 @@
+import React from 'react';
+import {
+  widgetDidUpdate,
+  widgetWillUpdate
+  } from "../../core/actions/widget";
+import ItemsGetter from '../../components/feed/ItemsGetter';
+import Feed from '../../components/feed/Feed';
+import WidgetChrome from '../../components/WidgetChrome';
+
+
+export default class Widget extends React.PureComponent {
+
+  propTypes: {
+    widget: PropTypes.object.isRequired,
+    editMode: PropTypes.bool,
+    needsUpdate: PropTypes.bool,
+  }
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      items: [],
+      loaded: false,
+    };
+    this.handleServerData = this.handleServerData.bind(this);
+    this.handleServerRequestStart = this.handleServerRequestStart.bind(this);
+  }
+
+  handleServerData(data) {
+    this.setState({
+      items: data,
+      loaded: true,
+    },
+      () => (
+        this.props.dispatch(widgetDidUpdate(this.props.widget.idx))
+      )
+    );
+  }
+
+  handleServerRequestStart() {
+    this.props.dispatch(widgetWillUpdate(this.props.widget.idx));
+  }
+
+  render() {
+    return (
+      <WidgetChrome
+        widget={this.props.widget}
+        editMode={this.props.editMode}
+        >
+        <ItemsGetter
+          url={this.props.widget.url}
+          useSSE={true}
+          needsUpdate={this.props.needsUpdate}
+          handleData={this.handleServerData}
+          onRequestStart={this.handleServerRequestStart}
+          >
+          <Feed
+            items={this.state.items}
+            loaded={this.state.loaded}
+            />
+        </ItemsGetter>
+      </WidgetChrome>
+    );
+  }
+
+}
diff --git a/hubs/static/client/app/widgets/feed/Widget.jsx b/hubs/static/client/app/widgets/feed/Widget.jsx
deleted file mode 100644
index b60a6eb..0000000
--- a/hubs/static/client/app/widgets/feed/Widget.jsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import React from 'react';
-import {IntlProvider} from 'react-intl';
-import ItemsGetter from '../../components/feed/ItemsGetter.jsx';
-import Feed from '../../components/feed/Feed.jsx';
-
-
-export default class Widget extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.state = {
-      items: [],
-      loaded: false,
-    };
-    this.handleServerData = this.handleServerData.bind(this);
-  }
-
-  handleServerData(data) {
-    this.setState({
-      items: data,
-      loaded: true,
-    });
-  }
-
-  render() {
-    return (
-      <IntlProvider locale={navigator.language}>
-        <ItemsGetter
-          url={this.props.url}
-          sse={{
-            eventName: "hubs:widget-updated",
-            shouldReload: (idx) => (parseInt(idx) === this.props.widgetIdx),
-          }}
-          handleData={this.handleServerData}
-          >
-          <Feed
-            items={this.state.items}
-            loaded={this.state.loaded}
-            />
-        </ItemsGetter>
-      </IntlProvider>
-    );
-  }
-
-}
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/widgets/halp/Config.js b/hubs/static/client/app/widgets/halp/Config.js
new file mode 100644
index 0000000..8f48cff
--- /dev/null
+++ b/hubs/static/client/app/widgets/halp/Config.js
@@ -0,0 +1,206 @@
+import React from 'react';
+import {
+  defineMessages,
+  injectIntl,
+  FormattedMessage,
+  } from 'react-intl';
+import CompletionInput from '../../components/CompletionInput';
+import "./halp.css";
+
+
+const messages = defineMessages({
+  title_add: {
+    id: "hubs.widgets.halp.config.title.add",
+    defaultMessage: "Adding \"halp\" widget",
+  },
+  title_edit: {
+    id: "hubs.widgets.halp.config.title.edit",
+    defaultMessage: "Configuring \"halp\" widget",
+  },
+  followed_hubs: {
+    id: "hubs.widgets.halp.config.followed_hubs",
+    defaultMessage: "Followed hubs:",
+  },
+  no_hubs: {
+    id: "hubs.widgets.halp.config.no_hubs",
+    defaultMessage: "No hubs. Use the form to the right to add some.",
+  },
+  main_title: {
+    id: "hubs.widgets.halp.config.main_title",
+    defaultMessage: "Track help requests in hubs",
+  },
+  main_subtitle: {
+    id: "hubs.widgets.halp.config.main_subtitle",
+    defaultMessage: "Enter a team name below to add it to the list of hubs this widget will follow.",
+  },
+  placeholder: {
+    id: "hubs.widgets.halp.config.placeholder",
+    defaultMessage: "Enter team name here...",
+  },
+  add: {
+    id: "hubs.widgets.halp.config.add",
+    defaultMessage: "Add",
+  },
+  per_page: {
+    id: "hubs.widgets.halp.config.per_page",
+    defaultMessage: "Requests per page:",
+  },
+  per_page_help: {
+    id: "hubs.widgets.halp.config.per_page_help",
+    defaultMessage: "The number of requests per page to display.",
+  },
+  close: {
+    id: "hubs.widgets.halp.config.close",
+    defaultMessage: "Close",
+  },
+  cancel: {
+    id: "hubs.widgets.halp.config.cancel",
+    defaultMessage: "Cancel",
+  },
+  save: {
+    id: "hubs.widgets.halp.config.save",
+    defaultMessage: "Save",
+  },
+  remove_widget: {
+    id: "hubs.widgets.halp.config.remove_widget",
+    defaultMessage: "Remove this widget",
+  },
+});
+
+
+export default class Config extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      selectedHub: null,
+    }
+    this.getFollowedHubs = this.getFollowedHubs.bind(this);
+    this.handleAddClicked = this.handleAddClicked.bind(this);
+    this.handleHubSelected = this.handleHubSelected.bind(this);
+    this.handlePerPageChanged = this.handlePerPageChanged.bind(this);
+  }
+
+  getFollowedHubs() {
+    return this.props.values.hubs || [];
+  }
+
+  addHub(name) {
+    let config = this.getFollowedHubs();
+    if (config.indexOf(name) === -1) {
+      config.push(name);
+    }
+    this.props.setConfig("hubs", config);
+  }
+
+  removeHub(name) {
+    let config = this.getFollowedHubs();
+    config.splice(config.indexOf(name), 1);
+    this.props.setConfig("hubs", config);
+  }
+
+  handleAddClicked(e) {
+    e.preventDefault();
+    if (this.state.selectedHub === null) {
+      console.log("No hub to add!");
+      return;
+    }
+    this.addHub(this.state.selectedHub);
+    this.setState({selectedHub: null});
+  }
+
+  handleHubSelected(e) {
+    this.setState({selectedHub: e.target.value});
+  }
+
+  handlePerPageChanged(e) {
+    this.props.setConfig("per_page", e.target.value);
+  }
+
+  render() {
+    let followedHubs = this.getFollowedHubs();
+    followedHubs = followedHubs.map((hub, idx) => {
+      return (
+        <li key={hub}>
+          <span className="hub">{hub}</span>
+          <span className="fa fa-times remove-hub"
+                onClick={(e) => {this.removeHub(hub)}}
+            />
+        </li>
+        );
+    });
+
+    /*
+    const IntlCompletionInput = injectIntl((props) => (
+        <CompletionInput
+          inputProps={{
+            name: "hub-add",
+            placeholder: props.intl.formatMessage(messages.placeholder),
+          }}
+          url={props.widget.urls.allHubs}
+          onChange={this.handleHubSelected}
+          />
+    ));
+              //<IntlCompletionInput {...this.props} />
+    */
+
+    return (
+      <div className="row widget-halp-config">
+        <div className="col-sm-4 hubs-list">
+          <FormattedMessage {...messages.followed_hubs} tagName="h5" />
+          {followedHubs.length === 0 ?
+            <p>
+              <FormattedMessage {...messages.no_hubs} tagName="small" />
+            </p>
+          :
+            <ul className="list-unstyled">
+              {followedHubs}
+            </ul>
+          }
+        </div>
+        <div className="col-sm-8 form-side">
+          <FormattedMessage {...messages.main_title} tagName="h4" />
+          <FormattedMessage {...messages.main_subtitle} tagName="p" />
+          <form onSubmit={this.handleAddClicked}>
+            <fieldset className="row">
+              <div className="col-sm-9">
+                <CompletionInput
+                  inputProps={{
+                    name: "hub-add",
+                    placeholder: "Enter team name here...",
+                    value: this.state.selectedHub || "",
+                  }}
+                  url={this.props.widget.urls.allHubs}
+                  onChange={this.handleHubSelected}
+                  />
+              </div>
+              <div className="col-sm-3">
+                <button className="btn btn-info" onClick={this.handleAddClicked}>
+                  <FormattedMessage {...messages.add} />
+                </button>
+              </div>
+            </fieldset>
+          </form>
+          <fieldset className="row">
+            <label className="col-sm-6 control-label">
+              <FormattedMessage {...messages.per_page} />
+            </label>
+            <div className="col-sm-6">
+              <input
+                name="per_page"
+                type="number"
+                value={this.props.values.per_page || "5"}
+                onChange={this.handlePerPageChanged}
+                className="form-control input-sm"
+                />
+            </div>
+            <div className="col-sm-12 text-muted">
+              <FormattedMessage {...messages.per_page_help} tagName="small" />
+            </div>
+          </fieldset>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/hubs/static/client/app/widgets/halp/Config.jsx b/hubs/static/client/app/widgets/halp/Config.jsx
deleted file mode 100644
index 61a3e50..0000000
--- a/hubs/static/client/app/widgets/halp/Config.jsx
+++ /dev/null
@@ -1,227 +0,0 @@
-import React from 'react';
-import {
-  IntlProvider,
-  defineMessages,
-  injectIntl,
-  FormattedMessage,
-  } from 'react-intl';
-import CompletionInput from '../../components/CompletionInput.jsx';
-
-
-const messages = defineMessages({
-  title_add: {
-    id: "hubs.widgets.halp.config.title.add",
-    defaultMessage: "Adding \"halp\" widget",
-  },
-  title_edit: {
-    id: "hubs.widgets.halp.config.title.edit",
-    defaultMessage: "Configuring \"halp\" widget",
-  },
-  followed_hubs: {
-    id: "hubs.widgets.halp.config.followed_hubs",
-    defaultMessage: "Followed hubs:",
-  },
-  no_hubs: {
-    id: "hubs.widgets.halp.config.no_hubs",
-    defaultMessage: "No hubs. Use the form to the right to add some.",
-  },
-  main_title: {
-    id: "hubs.widgets.halp.config.main_title",
-    defaultMessage: "Track help requests in hubs",
-  },
-  main_subtitle: {
-    id: "hubs.widgets.halp.config.main_subtitle",
-    defaultMessage: "Enter a team name below to add it to the list of hubs this widget will follow.",
-  },
-  placeholder: {
-    id: "hubs.widgets.halp.config.placeholder",
-    defaultMessage: "Enter team name here...",
-  },
-  add: {
-    id: "hubs.widgets.halp.config.add",
-    defaultMessage: "Add",
-  },
-  per_page: {
-    id: "hubs.widgets.halp.config.per_page",
-    defaultMessage: "Requests per page:",
-  },
-  per_page_help: {
-    id: "hubs.widgets.halp.config.per_page_help",
-    defaultMessage: "The number of requests per page to display.",
-  },
-  close: {
-    id: "hubs.widgets.halp.config.close",
-    defaultMessage: "Close",
-  },
-  cancel: {
-    id: "hubs.widgets.halp.config.cancel",
-    defaultMessage: "Cancel",
-  },
-  save: {
-    id: "hubs.widgets.halp.config.save",
-    defaultMessage: "Save",
-  },
-  remove_widget: {
-    id: "hubs.widgets.halp.config.remove_widget",
-    defaultMessage: "Remove this widget",
-  },
-});
-
-
-export default class Config extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.state = this.props.initialState;
-    this.addHub = this.addHub.bind(this);
-    this.removeHub = this.removeHub.bind(this);
-    this.onAddClick = this.onAddClick.bind(this);
-  }
-
-  addHub(name) {
-    // React may batch state updates:
-    // https://facebook.github.io/react/docs/state-and-lifecycle.html#state-updates-may-be-asynchronous
-    this.setState(function(prevState, props) {
-      if (prevState.hubs.indexOf(name) === -1) {
-        prevState.hubs.push(name);
-      }
-      return {hubs: prevState.hubs};
-    });
-  }
-
-  removeHub(name) {
-    // React may batch state updates:
-    // https://facebook.github.io/react/docs/state-and-lifecycle.html#state-updates-may-be-asynchronous
-    this.setState(function(prevState, props) {
-      prevState.hubs.splice(prevState.hubs.indexOf(name), 1);
-      return {hubs: prevState.hubs};
-    });
-  }
-
-  onAddClick(e) {
-    e.preventDefault();
-    var hubName = $(this.formDom).find("input[name='hub-add']").val();
-    this.addHub(hubName);
-  }
-
-  render() {
-    var titleMsg, followedHubs;
-    if (this.props.mode == "add") {
-      titleMsg = messages.title_add;
-    } else {
-      titleMsg = messages.title_edit;
-    }
-
-    followedHubs = this.state.hubs.map(function(hub, idx) {
-      return (
-        <li key={hub}>
-          <span className="hub">{hub}</span>
-          <span className="fa fa-times remove-hub"
-                onClick={(e) => this.removeHub(hub)}
-            />
-        </li>
-        );
-    }.bind(this));
-
-    const IntlCompletionInput = injectIntl((props) => (
-        <CompletionInput
-          inputProps={{
-            name: "hub-add",
-            placeholder: props.intl.formatMessage(messages.placeholder),
-          }}
-          url={props.urls.hubs}
-          />
-    ));
-
-    return (
-      <IntlProvider locale={navigator.language}>
-        <div className={"modal-dialog widget-halp-config widget-" + this.props.mode}>
-          <div className="modal-content">
-            <form method="post" action={this.props.urls.post}
-                  ref={(ref) => this.formDom = ref}>
-              <div className="modal-header">
-                <button type="button" className="close" data-dismiss="modal">&times;</button>
-                <h4 className="modal-title">
-                  <FormattedMessage {...titleMsg} />
-                </h4>
-              </div>
-              <div className="modal-body row">
-                <div className="col-sm-4 hubs-list">
-                  <FormattedMessage {...messages.followed_hubs} tagName="h5" />
-                  {this.state.hubs.length === 0 ?
-                    <FormattedMessage {...messages.no_hubs} tagName="p" />
-                  :
-                    <ul className="list-unstyled">
-                      {followedHubs}
-                    </ul>
-                  }
-                </div>
-                <div className="col-sm-8 form-side">
-                  <FormattedMessage {...messages.main_title} tagName="h4" />
-                  <FormattedMessage {...messages.main_subtitle} tagName="p" />
-                  <input type="hidden" name="hubs" value={this.state.hubs.join(",")} />
-                  <fieldset className="row">
-                    <div className="col-sm-9">
-                      <IntlCompletionInput {...this.props} />
-                    </div>
-                    <div className="col-sm-3">
-                      <button className="btn btn-info" onClick={this.onAddClick}>
-                        <FormattedMessage {...messages.add} />
-                      </button>
-                    </div>
-                  </fieldset>
-                  <fieldset className="row">
-                    <label className="col-sm-6 control-label">
-                      <FormattedMessage {...messages.per_page} />
-                    </label>
-                    <div className="col-sm-6">
-                      <input
-                        name="per_page"
-                        type="number"
-                        value={this.state.per_page}
-                        onChange={(e) => this.setState({per_page: e.target.value})}
-                        className="form-control input-sm"
-                        />
-                    </div>
-                    <div className="col-sm-12 text-muted">
-                      <FormattedMessage {...messages.per_page_help} tagName="small" />
-                    </div>
-                  </fieldset>
-                </div>
-              </div>
-              <div className="modal-footer">
-                <button type="button" className="btn btn-secondary" data-dismiss="modal">
-                  {this.props.mode === "add" ?
-                    <FormattedMessage {...messages.cancel} />
-                  :
-                    <FormattedMessage {...messages.close} />
-                  }
-                </button>
-                <button type="submit" className="btn btn-primary"
-                        disabled={this.state.hubs.length === 0 ? true : null}>
-                {this.props.mode === "add" ?
-                    <FormattedMessage {...messages.add} />
-                :
-                    <FormattedMessage {...messages.save} />
-                }
-                </button>
-              </div>
-            </form>
-            {this.props.mode !== "add" &&
-              <form method="POST" action={this.props.urls.remove} className="modal-footer p-abs" >
-                  <button type="submit" className="btn btn-danger" id="delete_widget">
-                    <FormattedMessage {...messages.remove_widget} />
-                  </button>
-              </form>
-            }
-          </div>
-        </div>
-      </IntlProvider>
-    );
-  }
-
-}
-
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/widgets/halp/Halp.js b/hubs/static/client/app/widgets/halp/Halp.js
deleted file mode 100644
index 5b821f5..0000000
--- a/hubs/static/client/app/widgets/halp/Halp.js
+++ /dev/null
@@ -1,4 +0,0 @@
-import Widget from './Widget.jsx';
-import Config from './Config.jsx';
-
-module.exports = {Widget, Config};
diff --git a/hubs/static/client/app/widgets/halp/Hub.js b/hubs/static/client/app/widgets/halp/Hub.js
new file mode 100644
index 0000000..7728662
--- /dev/null
+++ b/hubs/static/client/app/widgets/halp/Hub.js
@@ -0,0 +1,20 @@
+import React from 'react';
+
+
+export default class Hub extends React.Component {
+
+  onClick(e) {
+    e.preventDefault();
+    this.props.onClick(this.props.name);
+  }
+
+  render() {
+    var activeClass = this.props.active ? "active" : "inactive";
+    return (
+      <span
+        className={"hub " + activeClass}
+        onClick={this.onClick.bind(this)}
+        >{"@" + this.props.name}</span>
+    );
+  }
+}
diff --git a/hubs/static/client/app/widgets/halp/Hub.jsx b/hubs/static/client/app/widgets/halp/Hub.jsx
deleted file mode 100644
index 044e6d9..0000000
--- a/hubs/static/client/app/widgets/halp/Hub.jsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import React from 'react';
-
-
-export default class Hub extends React.Component {
-
-  onClick(e) {
-    e.preventDefault();
-    this.props.onClick(this.props.name);
-  }
-
-  render() {
-    var activeClass = this.props.active ? "active" : "inactive";
-    return (
-      <span
-        className={"hub " + activeClass}
-        onClick={this.onClick.bind(this)}
-        >{"@" + this.props.name}</span>
-    );
-  }
-}
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/widgets/halp/HubsList.js b/hubs/static/client/app/widgets/halp/HubsList.js
new file mode 100644
index 0000000..9f39afe
--- /dev/null
+++ b/hubs/static/client/app/widgets/halp/HubsList.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import { defineMessages, FormattedMessage } from 'react-intl';
+import Hub from './Hub';
+
+
+const messages = defineMessages({
+  watching_all_hubs: {
+    id: "hubs.widgets.halp.watching_all_hubs",
+    defaultMessage: "Watching all hubs"
+  },
+});
+
+
+export default class HubsList extends React.Component {
+  render() {
+    var hubNodes = this.props.hubs.map(function(hub, idx) {
+      return (
+        <Hub
+          name={hub.name}
+          active={hub.active}
+          onClick={this.props.onHubClick}
+          key={idx}
+          />
+      );
+    }.bind(this));
+    return (
+      <div className="hubs">
+        <span className="fa fa-tags" aria-hidden="true"></span>
+        { (hubNodes.length !== 0) ?
+            hubNodes
+          :
+            <FormattedMessage
+              tagName="em"
+              {...messages.watching_all_hubs}
+              />
+        }
+      </div>
+    );
+  }
+}
diff --git a/hubs/static/client/app/widgets/halp/HubsList.jsx b/hubs/static/client/app/widgets/halp/HubsList.jsx
deleted file mode 100644
index 3915dd4..0000000
--- a/hubs/static/client/app/widgets/halp/HubsList.jsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import React from 'react';
-import { defineMessages, FormattedMessage } from 'react-intl';
-import Hub from './Hub.jsx';
-
-
-const messages = defineMessages({
-  watching_all_hubs: {
-    id: "hubs.widgets.halp.watching_all_hubs",
-    defaultMessage: "Watching all hubs"
-  },
-});
-
-
-export default class HubsList extends React.Component {
-  render() {
-    var hubNodes = this.props.hubs.map(function(hub, idx) {
-      return (
-        <Hub
-          name={hub.name}
-          active={hub.active}
-          onClick={this.props.onHubClick}
-          key={idx}
-          />
-      );
-    }.bind(this));
-    return (
-      <div className="hubs row">
-        <span className="fa fa-tags" aria-hidden="true"></span>
-        { (hubNodes.length !== 0) ?
-            hubNodes
-          :
-            <FormattedMessage
-              tagName="em"
-              {...messages.watching_all_hubs}
-              />
-        }
-      </div>
-    );
-  }
-}
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/widgets/halp/ModalAllRequests.js b/hubs/static/client/app/widgets/halp/ModalAllRequests.js
new file mode 100644
index 0000000..8f0e1a8
--- /dev/null
+++ b/hubs/static/client/app/widgets/halp/ModalAllRequests.js
@@ -0,0 +1,238 @@
+import React from 'react';
+import { defineMessages, FormattedMessage } from 'react-intl';
+import CompletionInput from '../../components/CompletionInput';
+import Spinner from "../../components/Spinner";
+import Modal from '../../components/Modal';
+import Request from './Request';
+
+
+const messages = defineMessages({
+  no_request_found: {
+    id: "hubs.widgets.halp.no_request_found",
+    defaultMessage: "No request found."
+  },
+  search_meeting_name: {
+    id: "hubs.widgets.halp.search.meeting_name",
+    defaultMessage: "Meeting name"
+  },
+  search_between_dates: {
+    id: "hubs.widgets.halp.search.between_dates",
+    defaultMessage: "Between dates"
+  },
+  search_people: {
+    id: "hubs.widgets.halp.search.people",
+    defaultMessage: "People"
+  },
+  search_button: {
+    id: "hubs.widgets.halp.search.button",
+    defaultMessage: "Search"
+  },
+  pager_newer: {
+    id: "hubs.widgets.halp.pager.newer",
+    defaultMessage: "Newer"
+  },
+  pager_older: {
+    id: "hubs.widgets.halp.pager.older",
+    defaultMessage: "Older"
+  },
+  pager_status: {
+    id: "hubs.widgets.halp.pager.status",
+    defaultMessage: "Page {page} / {total}"
+  },
+});
+
+
+export default class ModalAllRequests extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      requests: [],
+      page: {nr: 1, has_prev: false, has_next: false},
+      formData: {
+        hubs: this.props.hubs.filter(
+            function(hub) { return hub.active; }
+          ).map(
+            function(hub) { return hub.name; }
+          ),
+        },
+      isLoading: false,
+    };
+    this.onSubmit = this.onSubmit.bind(this);
+    this.loadFromServer = this.loadFromServer.bind(this);
+    this.onPageChange = this.onPageChange.bind(this);
+    this.handleChange = this.handleChange.bind(this);
+  }
+
+  componentDidMount() {
+    if (this.state.requests.length === 0) {
+      this.loadFromServer();
+    }
+  }
+
+  componentWillUnmount() {
+    if (this.serverRequest) {
+      this.serverRequest.abort()
+    };
+  }
+
+  onSubmit(e) {
+    e.preventDefault();
+    this.loadFromServer();
+  }
+
+  onPageChange(e) {
+    const inc = parseInt(e.target.value);
+    const requestedPage = this.state.page.nr + inc;
+    this.loadFromServer(requestedPage);
+  }
+
+  loadFromServer(requestedPage) {
+    const formData = this.state.formData;
+    formData.page = requestedPage || 1;
+    this.setState({isLoading: true});
+    this.serverRequest = $.ajax({
+      url: this.props.urls.search,
+      method: 'GET',
+      data: formData,
+      dataType: 'json',
+      cache: false,
+      success: function(data) {
+        this.setState({
+          requests: data.data.requests,
+          page: data.page,
+          isLoading: false
+        });
+      }.bind(this),
+      error: function(xhr, status, err) {
+        console.error(this.props.urls.search, status, err.toString());
+        this.setState({isLoading: false});
+      }.bind(this)
+    });
+  }
+
+  handleChange(e) {
+    const name = e.target.name;
+    const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
+    this.setState((prevState, props) => {
+      prevState.formData[name] = value;
+      return {formData: prevState.formData};
+    });
+  }
+
+  render() {
+    let footer;
+    if (this.state.requests.length === 0
+        || (!this.state.page.has_prev && !this.state.page.has_next)) {
+      // No results or only one page: don't show the pager.
+      footer = null;
+    } else {
+      footer = (
+        <nav aria-label="...">
+          <ul className="pager">
+            <li className={"pager-prev" + (this.state.page.has_prev ? "" : " disabled")}>
+              <button className="btn btn-link" value="-1" onClick={this.onPageChange}>
+                <span aria-hidden="true">&larr;</span>
+                <FormattedMessage {...messages.pager_newer} />
+              </button>
+            </li>
+            <FormattedMessage
+              tagName="li"
+              {...messages.pager_status}
+              values={{page: this.state.page.nr, total: this.state.page.total_pages}}
+              />
+            <li className={"pager-next" + (this.state.page.has_next ? "" : " disabled")}>
+              <button className="btn btn-link" value="1" onClick={this.onPageChange}>
+                <FormattedMessage {...messages.pager_older} />
+                <span aria-hidden="true">&rarr;</span>
+              </button>
+            </li>
+          </ul>
+        </nav>
+      );
+    }
+
+    let requestNodes;
+    if (this.state.requests.length === 0) {
+      requestNodes = (
+        <p className="no-request">
+          <FormattedMessage {...messages.no_request_found} />
+        </p>
+      );
+    } else {
+      requestNodes = this.state.requests.map((request, idx) => {
+        return (
+          <Request
+            request={request}
+            showdate={false}
+            onZoomRequest={this.props.onZoomRequest}
+            key={idx} />
+        );
+      });
+    }
+
+    return (
+      <Modal
+        title={this.props.title}
+        footer={footer}
+        onCloseClicked={this.props.onCloseClicked}
+        cssClass="ModalAllRequests"
+        >
+        <form className="" method="post"
+              action={this.props.urls.search} onSubmit={this.onSubmit}>
+          <div className="form-group row">
+            <label htmlFor="meetingname" className="col-sm-4 control-label">
+              <FormattedMessage {...messages.search_meeting_name} />
+            </label>
+            <div className="col-sm-8">
+              <input type="text" className="form-control" name="meetingname"
+                     onChange={this.handleChange} />
+            </div>
+          </div>
+          <div className="form-group row">
+            <label htmlFor="startdate" className="col-sm-4 control-label">
+              <FormattedMessage {...messages.search_between_dates} />
+            </label>
+            <div className="col-sm-4">
+              <input type="date" className="form-control" name="startdate"
+                     onChange={this.handleChange} />
+            </div>
+            <div className="col-sm-4">
+              <input type="date" className="form-control" name="enddate"
+                     onChange={this.handleChange} />
+            </div>
+          </div>
+          <div className="form-group row">
+            <label htmlFor="people" className="col-sm-4 control-label">
+              <FormattedMessage {...messages.search_people} />
+            </label>
+            <div className="col-sm-8">
+              <CompletionInput
+                inputProps={{name: "people"}}
+                url={this.props.urls.requesters}
+                onChange={this.handleChange}
+                />
+            </div>
+          </div>
+          <div className="form-group row">
+            <div className="offset-sm-4 col-sm-8">
+              <button
+                type="submit"
+                className="btn btn-primary mr-3"
+                >
+                <FormattedMessage {...messages.search_button} />
+              </button>
+              { this.state.isLoading &&
+                  <Spinner circle={true} />
+              }
+            </div>
+          </div>
+        </form>
+        <hr />
+        <div className="requests">
+          {requestNodes}
+        </div>
+      </Modal>
+    );
+  }
+}
diff --git a/hubs/static/client/app/widgets/halp/ModalAllRequests.jsx b/hubs/static/client/app/widgets/halp/ModalAllRequests.jsx
deleted file mode 100644
index e7365a9..0000000
--- a/hubs/static/client/app/widgets/halp/ModalAllRequests.jsx
+++ /dev/null
@@ -1,240 +0,0 @@
-import { defineMessages, FormattedMessage } from 'react-intl';
-import CompletionInput from '../../components/CompletionInput.jsx';
-import Modal from '../../components/Modal.jsx';
-import Request from './Request.jsx';
-
-
-const messages = defineMessages({
-  no_request_found: {
-    id: "hubs.widgets.halp.no_request_found",
-    defaultMessage: "No request found."
-  },
-  search_meeting_name: {
-    id: "hubs.widgets.halp.search.meeting_name",
-    defaultMessage: "Meeting name"
-  },
-  search_between_dates: {
-    id: "hubs.widgets.halp.search.between_dates",
-    defaultMessage: "Between dates"
-  },
-  search_people: {
-    id: "hubs.widgets.halp.search.people",
-    defaultMessage: "People"
-  },
-  search_button: {
-    id: "hubs.widgets.halp.search.button",
-    defaultMessage: "Search"
-  },
-  pager_newer: {
-    id: "hubs.widgets.halp.pager.newer",
-    defaultMessage: "Newer"
-  },
-  pager_older: {
-    id: "hubs.widgets.halp.pager.older",
-    defaultMessage: "Older"
-  },
-  pager_status: {
-    id: "hubs.widgets.halp.pager.status",
-    defaultMessage: "Page {page} / {total}"
-  },
-});
-
-
-export default class ModalAllRequests extends Modal {
-
-  constructor(props) {
-    super(props);
-    this.state = {
-      requests: [],
-      page: {nr: 1, has_prev: false, has_next: false},
-      formData: {
-        hubs: this.props.hubs.filter(
-            function(hub) { return hub.active; }
-          ).map(
-            function(hub) { return hub.name; }
-          ),
-        },
-      loading: false,
-    };
-    this.onSubmit = this.onSubmit.bind(this);
-    this.loadFromServer = this.loadFromServer.bind(this);
-    this.onZoomRequest = this.onZoomRequest.bind(this);
-    this.changePage = this.changePage.bind(this);
-    this.handleChange = this.handleChange.bind(this);
-  }
-
-  componentWillUnmount() {
-    super.componentWillUnmount();
-    this.serverRequest.abort();
-  }
-
-  onShown(e) {
-    super.onShown(e);
-    if (this.state.requests.length === 0) { this.loadFromServer(); }
-  }
-
-  onHidden(e) {
-    super.onHidden(e);
-    if (this.serverRequest) { this.serverRequest.abort(); }
-  }
-
-  onZoomRequest(request) {
-    // Close this modal and open the request modal
-    this.hide(function() {
-      this.props.onZoomRequest(request);
-    }.bind(this));
-  }
-
-  onSubmit(e) {
-    e.preventDefault();
-    this.loadFromServer();
-  }
-
-  changePage(inc) {
-    var requestedPage = this.state.page.nr + inc;
-    this.loadFromServer(requestedPage);
-  }
-
-  loadFromServer(requestedPage) {
-    const formData = this.state.formData;
-    formData.page = requestedPage || 1;
-    this.setState({loading: true});
-    this.serverRequest = $.ajax({
-      url: this.props.urls.search,
-      method: 'GET',
-      data: formData,
-      dataType: 'json',
-      cache: false,
-      success: function(data) {
-        this.setState({
-          requests: data.requests,
-          page: data.page,
-          loading: false
-        });
-      }.bind(this),
-      error: function(xhr, status, err) {
-        console.error(this.props.urls.search, status, err.toString());
-        this.setState({loading: false});
-      }.bind(this)
-    });
-  }
-
-  handleChange(e) {
-    const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
-    const name = e.target.name;
-    var data = this.state.formData;
-    data[name] = value;
-    this.setState({formData: data});
-  }
-
-  getModalBody() {
-    var requestNodes;
-    if (this.state.requests.length === 0) {
-      requestNodes = (<p className="no-request">
-        <FormattedMessage {...messages.no_request_found} />
-      </p>);
-    } else {
-      requestNodes = this.state.requests.map(function(request, idx) {
-        return (
-          <Request
-            request={request}
-            showdate={false}
-            onZoomRequest={this.onZoomRequest}
-            key={idx} />
-        );
-      }.bind(this));
-    }
-    return (
-      <div>
-        <form className="" method="post"
-              action={this.props.urls.search} onSubmit={this.onSubmit}>
-          <div className="form-group row">
-            <label htmlFor="meetingname" className="col-sm-4 control-label">
-              <FormattedMessage {...messages.search_meeting_name} />
-            </label>
-            <div className="col-sm-8">
-              <input type="text" className="form-control" name="meetingname"
-                     onChange={this.handleChange} />
-            </div>
-          </div>
-          <div className="form-group row">
-            <label htmlFor="startdate" className="col-sm-4 control-label">
-              <FormattedMessage {...messages.search_between_dates} />
-            </label>
-            <div className="col-sm-4">
-              <input type="date" className="form-control" name="startdate"
-                     onChange={this.handleChange} />
-            </div>
-            <div className="col-sm-4">
-              <input type="date" className="form-control" name="enddate"
-                     onChange={this.handleChange} />
-            </div>
-          </div>
-          <div className="form-group row">
-            <label htmlFor="people" className="col-sm-4 control-label">
-              <FormattedMessage {...messages.search_people} />
-            </label>
-            <div className="col-sm-8">
-              <CompletionInput
-                inputProps={{name: "people"}}
-                url={this.props.urls.requesters}
-                onChange={this.handleChange}
-                />
-            </div>
-          </div>
-          <div className="form-group row">
-            <div className="offset-sm-4 col-sm-8">
-              <button
-                type="submit"
-                className="btn btn-primary mr-3"
-                >
-                <FormattedMessage {...messages.search_button} />
-              </button>
-              <div
-                className="loading-circle"
-                style={this.state.loading ? {display: "inline-block"} : {}}
-                ></div>
-            </div>
-          </div>
-        </form>
-        <hr />
-        <div className="requests">
-          {requestNodes}
-        </div>
-      </div>
-    );
-  }
-
-  getModalFooter() {
-    if (this.state.requests.length === 0
-        || (!this.state.page.has_prev && !this.state.page.has_next)) {
-      // No results or only one page: don't show the pager.
-      return null;
-    }
-    return (
-      <nav aria-label="...">
-        <ul className="pager">
-          <li className={"pager-prev" + (this.state.page.has_prev ? "" : " disabled")}>
-            <a href="#" onClick={this.changePage.bind(this, -1)}>
-              <span aria-hidden="true">&larr;</span>
-              <FormattedMessage {...messages.pager_newer} />
-              </a>
-          </li>
-          <FormattedMessage
-            tagName="li"
-            {...messages.pager_status}
-            values={{page: this.state.page.nr, total: this.state.page.total_pages}}
-            />
-          <li className={"pager-next" + (this.state.page.has_next ? "" : " disabled")}>
-            <a href="#" onClick={this.changePage.bind(this, 1)}>
-              <FormattedMessage {...messages.pager_older} />
-              <span aria-hidden="true">&rarr;</span></a>
-          </li>
-        </ul>
-      </nav>
-    );
-  }
-}
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/widgets/halp/ModalRequest.js b/hubs/static/client/app/widgets/halp/ModalRequest.js
new file mode 100644
index 0000000..36ed228
--- /dev/null
+++ b/hubs/static/client/app/widgets/halp/ModalRequest.js
@@ -0,0 +1,111 @@
+import React from 'react';
+import { defineMessages, FormattedMessage } from 'react-intl';
+import Modal from '../../components/Modal';
+import getRequestView from './getRequestView';
+
+
+const messages = defineMessages({
+  zoom_part2_title: {
+    id: "hubs.widgets.halp.zoom.part2_title",
+    defaultMessage: "Want to help with this request?"
+  },
+  zoom_part2_subtitle: {
+    id: "hubs.widgets.halp.zoom.part2_subtitle",
+    defaultMessage: "Awesome! Here's some tips on getting started:"
+  },
+  zoom_tip1: {
+    id: "hubs.widgets.halp.zoom.tip1",
+    defaultMessage: "Take a look at the link provided in the request. It should have more information about the problem and may have instructions on how to get starter helping out:"
+  },
+  zoom_tip2: {
+    id: "hubs.widgets.halp.zoom.tip2",
+    defaultMessage: "Contact the person who requested help to get more information. By clicking on their name, you'll be taken to their profile where you can contact them:"
+  },
+  zoom_tip3: {
+    id: "hubs.widgets.halp.zoom.tip3",
+    defaultMessage: "Read through the chat log where the request occured. There may be additional context in the log that can help you figure out how to get started:"
+  },
+});
+
+
+export default class ModalRequest extends React.Component {
+
+  render() {
+    let content;
+    if (!this.props.data) {
+      content = "";
+    } else {
+      content = (
+        <div className="zoomed-request">
+          {getRequestView(this.props.data)}
+          <hr />
+          <FormattedMessage
+            tagName="h4"
+            {...messages.zoom_part2_title}
+            />
+          <FormattedMessage
+            tag="p"
+            {...messages.zoom_part2_subtitle}
+            />
+          <ol>
+            { this.props.data.urls ?
+            <li>
+              <FormattedMessage
+                {...messages.zoom_tip1}
+                />
+              <ul>
+                { this.props.data.urls.map(function(url, index) {
+                  return (
+                    <li key={index}><a href={url}>
+                      {url.replace(/^https?:\/\//, '')}
+                    </a></li>);
+                  }) }
+              </ul>
+            </li>
+            : null }
+            <li>
+              <FormattedMessage
+                {...messages.zoom_tip2}
+                />
+              <ul className="list-unstyled">
+                <li className="media">
+                  <div className="media-left media-middle">
+                    <a href={this.props.data.author.url} target="_blank">
+                      <img className="square-32 img-circle"
+                           src={this.props.data.author.avatar} />
+                    </a>
+                  </div>
+                  <div className="media-body">
+                    <a href={this.props.data.author.url} target="_blank">
+                      {this.props.data.author.name}
+                    </a>
+                  </div>
+                </li>
+              </ul>
+            </li>
+            <li>
+              <FormattedMessage
+                {...messages.zoom_tip3}
+                />
+              <ul className="list-unstyled">
+                <a href={this.props.data.context_url}>
+                  {this.props.data.context_url.replace(/^https?:\/\//, '')}
+                </a>
+              </ul>
+            </li>
+          </ol>
+        </div>
+      );
+    }
+
+    return (
+      <Modal
+        title={this.props.title}
+        onCloseClicked={this.props.onCloseClicked}
+        cssClass="ModalRequest"
+        >
+        {content}
+      </Modal>
+    );
+  }
+}
diff --git a/hubs/static/client/app/widgets/halp/ModalRequest.jsx b/hubs/static/client/app/widgets/halp/ModalRequest.jsx
deleted file mode 100644
index 36ea130..0000000
--- a/hubs/static/client/app/widgets/halp/ModalRequest.jsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { defineMessages, FormattedMessage } from 'react-intl';
-import Modal from '../../components/Modal.jsx';
-import getRequestView from './getRequestView.jsx';
-
-
-const messages = defineMessages({
-  zoom_part2_title: {
-    id: "hubs.widgets.halp.zoom.part2_title",
-    defaultMessage: "Want to help with this request?"
-  },
-  zoom_part2_subtitle: {
-    id: "hubs.widgets.halp.zoom.part2_subtitle",
-    defaultMessage: "Awesome! Here's some tips on getting started:"
-  },
-  zoom_tip1: {
-    id: "hubs.widgets.halp.zoom.tip1",
-    defaultMessage: "Take a look at the link provided in the request. It should have more information about the problem and may have instructions on how to get starter helping out:"
-  },
-  zoom_tip2: {
-    id: "hubs.widgets.halp.zoom.tip2",
-    defaultMessage: "Contact the person who requested help to get more information. By clicking on their name, you'll be taken to their profile where you can contact them:"
-  },
-  zoom_tip3: {
-    id: "hubs.widgets.halp.zoom.tip3",
-    defaultMessage: "Read through the chat log where the request occured. There may be additional context in the log that can help you figure out how to get started:"
-  },
-});
-
-
-export default class ModalRequest extends Modal {
-
-  getModalBody() {
-    if (!this.props.data) { return ""; }
-    return (
-      <div className="zoomed-request">
-        {getRequestView(this.props.data)}
-        <hr />
-        <FormattedMessage
-          tagName="h4"
-          {...messages.zoom_part2_title}
-          />
-        <FormattedMessage
-          tag="p"
-          {...messages.zoom_part2_subtitle}
-          />
-        <ol>
-          { this.props.data.urls ?
-          <li>
-            <FormattedMessage
-              {...messages.zoom_tip1}
-              />
-            <ul>
-              { this.props.data.urls.map(function(url, index) {
-                return (
-                  <li key={index}><a href={url}>
-                    {url.replace(/^https?:\/\//, '')}
-                  </a></li>);
-                }) }
-            </ul>
-          </li>
-          : null }
-          <li>
-            <FormattedMessage
-              {...messages.zoom_tip2}
-              />
-            <ul className="list-unstyled">
-              <li className="media">
-                <div className="media-left media-middle">
-                  <a href={this.props.data.author.url} target="_blank">
-                    <img className="square-32 img-circle"
-                         src={this.props.data.author.avatar} />
-                  </a>
-                </div>
-                <div className="media-body">
-                  <a href={this.props.data.author.url} target="_blank">
-                    {this.props.data.author.name}
-                  </a>
-                </div>
-              </li>
-            </ul>
-          </li>
-          <li>
-            <FormattedMessage
-              {...messages.zoom_tip3}
-              />
-            <ul className="list-unstyled">
-              <a href={this.props.data.context_url}>
-                {this.props.data.context_url.replace(/^https?:\/\//, '')}
-              </a>
-            </ul>
-          </li>
-        </ol>
-      </div>
-    );
-  }
-}
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/widgets/halp/Request.js b/hubs/static/client/app/widgets/halp/Request.js
new file mode 100644
index 0000000..a6c0ec9
--- /dev/null
+++ b/hubs/static/client/app/widgets/halp/Request.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import {FormattedRelative} from 'react-intl';
+import getRequestView from './getRequestView';
+
+
+export default class Request extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.onClick = this.onClick.bind(this);
+  }
+
+  onClick(e) {
+    e.preventDefault;
+    this.props.onZoomRequest(this.props.request);
+  }
+
+  render() {
+    var requestView = getRequestView(
+      this.props.request,
+      this.props.onZoomRequest ? this.onClick : null);
+    return (
+      <div>
+        { this.props.showdate ?
+            <div className="date text-uppercase">
+              <FormattedRelative value={new Date(this.props.request.date * 1000)} />
+            </div>
+          : ""
+        }
+        {requestView}
+      </div>
+    );
+  }
+}
+Request.defaultProps = {
+    onZoomRequest: null,
+};
diff --git a/hubs/static/client/app/widgets/halp/Request.jsx b/hubs/static/client/app/widgets/halp/Request.jsx
deleted file mode 100644
index bc80c6c..0000000
--- a/hubs/static/client/app/widgets/halp/Request.jsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import React from 'react';
-import {FormattedRelative} from 'react-intl';
-import getRequestView from './getRequestView.jsx';
-
-
-export default class Request extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.onClick = this.onClick.bind(this);
-  }
-
-  onClick(e) {
-    e.preventDefault;
-    this.props.onZoomRequest(this.props.request);
-  }
-
-  render() {
-    var requestView = getRequestView(
-      this.props.request,
-      this.props.onZoomRequest ? this.onClick : null);
-    return (
-      <div>
-        { this.props.showdate ?
-            <div className="date text-uppercase">
-              <FormattedRelative value={new Date(this.props.request.date * 1000)} />
-            </div>
-          : ""
-        }
-        {requestView}
-      </div>
-    );
-  }
-}
-Request.defaultProps = {
-    onZoomRequest: null,
-};
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/widgets/halp/Widget.js b/hubs/static/client/app/widgets/halp/Widget.js
new file mode 100644
index 0000000..099adb2
--- /dev/null
+++ b/hubs/static/client/app/widgets/halp/Widget.js
@@ -0,0 +1,213 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+  defineMessages,
+  FormattedMessage,
+  } from 'react-intl';
+import {
+  widgetDidUpdate,
+  widgetWillUpdate
+  } from "../../core/actions/widget";
+import HubsList from './HubsList';
+import ModalRequest from './ModalRequest';
+import ModalAllRequests from './ModalAllRequests';
+import Request from './Request';
+import WidgetChrome from '../../components/WidgetChrome';
+import Spinner from "../../components/Spinner";
+import "./halp.css";
+
+
+const messages = defineMessages({
+  no_request: {
+    id: "hubs.widgets.halp.no_request",
+    defaultMessage: "No request for now."
+  },
+  view_more: {
+    id: "hubs.widgets.halp.view_more",
+    defaultMessage: "View more"
+  },
+  zoom_title: {
+    id: "hubs.widgets.halp.zoom.title",
+    defaultMessage: "Help request"
+  },
+  search_title: {
+    id: "hubs.widgets.halp.search.title",
+    defaultMessage: "All Help Requests"
+  },
+});
+
+
+export default class Widget extends React.Component {
+
+  propTypes: {
+    widget: PropTypes.object.isRequired,
+    editMode: PropTypes.bool,
+    needsUpdate: PropTypes.bool,
+  }
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      requests: [],
+      inactiveHubs: [],
+      zoomedRequest: null,
+      allRequestsOpen: false,
+      isLoading: false
+    };
+    this.zoomOnRequest = this.zoomOnRequest.bind(this);
+    this.onZoomClose = this.onZoomClose.bind(this);
+    this.openAllRequests = this.openAllRequests.bind(this);
+    this.onAllRequestsClose = this.onAllRequestsClose.bind(this);
+    this.onHubClick = this.onHubClick.bind(this);
+  }
+
+  componentDidMount() {
+    if (!this.props.editMode) {
+      this.loadFromServer();
+    }
+  }
+
+  componentWillUnmount() {
+    this.serverRequest.abort();
+  }
+
+  componentDidUpdate(prevProps, prevState) {
+    if (this.props.needsUpdate && !this.props.editMode && !this.state.isLoading) {
+      this.loadFromServer();
+    }
+  }
+
+  loadFromServer() {
+    let hubs = this.props.widget.config.hubs || [];
+    hubs = hubs.filter((hub) => (this.state.inactiveHubs.indexOf(hub) === -1));
+    const params = hubs.length > 0 ? $.param({hubs}, true) : "hubs=";
+    this.props.dispatch(widgetWillUpdate(this.props.widget.idx));
+    this.setState({isLoading: true});
+    this.serverRequest = $.ajax({
+      url: this.props.widget.urls.data,
+      method: 'GET',
+      data: params,
+      dataType: 'json',
+      cache: false,
+      success: (data) => {
+        this.setState({
+          requests: data.data.requests,
+          isLoading: false
+        },
+          () => (
+            this.props.dispatch(widgetDidUpdate(this.props.widget.idx))
+          )
+        );
+      },
+      error: (xhr, status, err) => {
+        console.error(this.props.widget.urls.data, status, err.toString());
+        this.setState({isLoading: false});
+      }
+    });
+  }
+
+  zoomOnRequest(request) {
+    this.setState({
+      zoomedRequest: request,
+      allRequestsOpen: false,
+      });
+  }
+  onZoomClose() {
+    this.setState({zoomedRequest: null});
+  }
+
+  openAllRequests(e) {
+    e.preventDefault();
+    this.setState({allRequestsOpen: true});
+  }
+  onAllRequestsClose() {
+    this.setState({allRequestsOpen: false});
+  }
+
+  onHubClick(hubName) {
+    var newInactiveHubs = this.state.inactiveHubs.slice(),
+        inactiveIndex = this.state.inactiveHubs.indexOf(hubName);
+    if (inactiveIndex === -1) {
+      newInactiveHubs.push(hubName);
+    } else {
+      newInactiveHubs.splice(inactiveIndex, 1);
+    }
+    this.setState(
+      { inactiveHubs: newInactiveHubs },
+      this.loadFromServer);
+  }
+
+  render() {
+    let requestNodes;
+    if (this.state.requests.length === 0) {
+      requestNodes = (
+        <p className="no-request">
+          <FormattedMessage {...messages.no_request} />
+        </p>
+        );
+    } else {
+      let previousDate = null;
+      requestNodes = this.state.requests.map(function(request, idx) {
+        const showDate = (request.date !== previousDate);
+        previousDate = request.date;
+        return (
+          <Request
+            request={request}
+            showdate={showDate}
+            onZoomRequest={this.zoomOnRequest}
+            key={idx} />
+        );
+      }.bind(this));
+    }
+    let hubs = this.props.widget.config.hubs || [];
+    hubs = hubs.map(function(hub) {
+      return {
+        name: hub,
+        active: (this.state.inactiveHubs.indexOf(hub) === -1)
+      }
+    }.bind(this));
+    return (
+      <WidgetChrome
+        widget={this.props.widget}
+        editMode={this.props.editMode}
+        >
+        <div className="widget-halp">
+          <HubsList
+            hubs={hubs}
+            onHubClick={this.onHubClick}
+            />
+          { this.state.isLoading ?
+              <Spinner />
+            :
+              <div className="requests">
+                {requestNodes}
+              </div>
+          }
+          <div className="view-more text-muted">
+            <button className="btn btn-secondary" onClick={this.openAllRequests}>
+              <FormattedMessage {...messages.view_more} />
+              &nbsp; <span className="fa fa-chevron-right" aria-hidden="true"></span>
+            </button>
+          </div>
+          { this.state.zoomedRequest &&
+          <ModalRequest
+            open={this.state.zoomedRequest !== null}
+            data={this.state.zoomedRequest}
+            title={<FormattedMessage {...messages.zoom_title} />}
+            onCloseClicked={this.onZoomClose}
+            />
+          }
+          { this.state.allRequestsOpen &&
+            <ModalAllRequests
+              onCloseClicked={this.onAllRequestsClose}
+              onZoomRequest={this.zoomOnRequest}
+              title={<FormattedMessage {...messages.search_title} />}
+              urls={this.props.widget.urls}
+              hubs={hubs}
+              />
+          }
+        </div>
+      </WidgetChrome>
+    );
+  }
+}
diff --git a/hubs/static/client/app/widgets/halp/Widget.jsx b/hubs/static/client/app/widgets/halp/Widget.jsx
deleted file mode 100644
index 8d64895..0000000
--- a/hubs/static/client/app/widgets/halp/Widget.jsx
+++ /dev/null
@@ -1,182 +0,0 @@
-import React from 'react';
-import {
-  IntlProvider,
-  defineMessages,
-  FormattedMessage,
-  } from 'react-intl';
-import HubsList from './HubsList.jsx';
-import ModalRequest from './ModalRequest.jsx';
-import ModalAllRequests from './ModalAllRequests.jsx';
-import Request from './Request.jsx';
-
-
-const messages = defineMessages({
-  no_request: {
-    id: "hubs.widgets.halp.no_request",
-    defaultMessage: "No request for now."
-  },
-  view_more: {
-    id: "hubs.widgets.halp.view_more",
-    defaultMessage: "View more"
-  },
-  zoom_title: {
-    id: "hubs.widgets.halp.zoom.title",
-    defaultMessage: "Help request"
-  },
-  search_title: {
-    id: "hubs.widgets.halp.search.title",
-    defaultMessage: "All Help Requests"
-  },
-});
-
-
-export default class Widget extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.state = {
-      requests: [],
-      inactiveHubs: [],
-      zoomedRequest: null,
-      allRequestsOpen: false,
-      loading: false
-    };
-    this.zoomOnRequest = this.zoomOnRequest.bind(this);
-    this.onZoomClose = this.onZoomClose.bind(this);
-    this.openAllRequests = this.openAllRequests.bind(this);
-    this.onAllRequestsClose = this.onAllRequestsClose.bind(this);
-    this.onHubClick = this.onHubClick.bind(this);
-  }
-
-  loadFromServer() {
-    this.setState({loading: true});
-    var data = { hubs: [] };
-    this.props.hubs.forEach(function(hub) {
-      if (this.state.inactiveHubs.indexOf(hub) === -1) {
-        data.hubs.push(hub);
-      }
-    }.bind(this));
-    this.serverRequest = $.ajax({
-      url: this.props.urls.data,
-      method: 'GET',
-      data: $.param(data, true),
-      dataType: 'json',
-      cache: false,
-      success: function(data) {
-        this.setState({
-          requests: data.requests,
-          loading: false
-        });
-      }.bind(this),
-      error: function(xhr, status, err) {
-        console.error(this.props.urls.data, status, err.toString());
-        this.setState({loading: false});
-      }.bind(this)
-    });
-  }
-
-  componentDidMount() {
-    this.loadFromServer();
-  }
-
-  componentWillUnmount() {
-    this.serverRequest.abort();
-  }
-
-  zoomOnRequest(request) {
-    this.setState({zoomedRequest: request});
-  }
-  onZoomClose() {
-    this.setState({zoomedRequest: null});
-  }
-
-  openAllRequests(e) {
-    e.preventDefault();
-    this.setState({allRequestsOpen: true});
-  }
-  onAllRequestsClose() {
-    this.setState({allRequestsOpen: false});
-  }
-
-  onHubClick(hubName) {
-    var newInactiveHubs = this.state.inactiveHubs.slice(),
-        inactiveIndex = this.state.inactiveHubs.indexOf(hubName);
-    if (inactiveIndex === -1) {
-      newInactiveHubs.push(hubName);
-    } else {
-      newInactiveHubs.splice(inactiveIndex, 1);
-    }
-    this.setState(
-      { inactiveHubs: newInactiveHubs },
-      this.loadFromServer);
-  }
-
-  render() {
-    var requestNodes;
-    if (this.state.requests.length === 0) {
-      requestNodes = (
-        <p className="no-request">
-          <FormattedMessage {...messages.no_request} />
-        </p>
-        );
-    } else {
-      var previousDate = null;
-      requestNodes = this.state.requests.map(function(request, idx) {
-        var showDate = (request.date !== previousDate);
-        previousDate = request.date;
-        return (
-          <Request
-            request={request}
-            showdate={showDate}
-            onZoomRequest={this.zoomOnRequest}
-            key={idx} />
-        );
-      }.bind(this));
-    }
-    var hubs = this.props.hubs.map(function(hub) {
-      return {
-        name: hub,
-        active: (this.state.inactiveHubs.indexOf(hub) === -1)
-      }
-    }.bind(this));
-    return (
-      <IntlProvider locale={navigator.language}>
-        <div className="widget-halp">
-          <HubsList
-            hubs={hubs}
-            onHubClick={this.onHubClick}
-            />
-          { this.state.loading ?
-              <div className="loading-circle mx-auto d-block"></div>
-            :
-              <div className="requests">
-                {requestNodes}
-              </div>
-          }
-          <div className="view-more text-muted">
-            <button className="btn btn-secondary" onClick={this.openAllRequests}>
-              <FormattedMessage {...messages.view_more} />
-              &nbsp; <span className="fa fa-chevron-right" aria-hidden="true"></span>
-            </button>
-          </div>
-          <ModalRequest
-            open={this.state.zoomedRequest !== null}
-            data={this.state.zoomedRequest}
-            title={<FormattedMessage {...messages.zoom_title} />}
-            onHidden={this.onZoomClose} />
-          <ModalAllRequests
-            open={this.state.allRequestsOpen}
-            onHidden={this.onAllRequestsClose.bind(this)}
-            onZoomRequest={this.zoomOnRequest}
-            title={<FormattedMessage {...messages.search_title} />}
-            urls={this.props.urls}
-            hubs={hubs}
-            />
-        </div>
-      </IntlProvider>
-    );
-  }
-}
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/widgets/halp/getRequestView.js b/hubs/static/client/app/widgets/halp/getRequestView.js
new file mode 100644
index 0000000..13e3d17
--- /dev/null
+++ b/hubs/static/client/app/widgets/halp/getRequestView.js
@@ -0,0 +1,64 @@
+import React from 'react';
+import { FormattedMessage, defineMessages } from 'react-intl';
+import Linkify from 'react-linkify';
+
+
+const messages = defineMessages({
+  req_no_text: {
+    id: "hubs.widgets.halp.req_no_text",
+    defaultMessage: "(no message)"
+  },
+});
+
+
+export default function getRequestView(request, openCallback) {
+    var openButton = "";
+    if (openCallback) {
+      openButton = (
+        <div className="media-right media-middle">
+          <button className="btn btn-secondary btn-sm" onClick={openCallback}>
+            <span className="fa fa-chevron-right" aria-hidden="true"></span>
+          </button>
+        </div>
+      );
+    }
+    return (
+      <div className="request row">
+        <div className="hub col-sm-6">
+          <span>@{request.hub}</span>
+        </div>
+        <div className="channel col-sm-6">
+          {request.channel}
+        </div>
+        <div className="text col-sm-12">
+          { request.text ?
+            <Linkify>{'"'}{request.text}{'"'}</Linkify>
+          :
+            <em><FormattedMessage {...messages.req_no_text } /></em>
+          }
+        </div>
+        { request.url ?
+        <div className="url col-sm-12">
+          <a href={request.url}>
+            {request.url.replace(/^https?:\/\//, '')}
+          </a>
+        </div>
+        : null }
+        <div className="author media col-sm-12">
+          <a
+            href={request.author.url} target="_blank"
+            className="d-flex mr-3"
+            >
+            <img className="square-32 img-circle"
+                 src={request.author.avatar} />
+          </a>
+          <div className="media-body align-self-center">
+            <a href={request.author.url}>
+              {request.author.name}
+            </a>
+          </div>
+          {openButton}
+        </div>
+      </div>
+    );
+}
diff --git a/hubs/static/client/app/widgets/halp/getRequestView.jsx b/hubs/static/client/app/widgets/halp/getRequestView.jsx
deleted file mode 100644
index 1f7627b..0000000
--- a/hubs/static/client/app/widgets/halp/getRequestView.jsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { FormattedMessage, defineMessages } from 'react-intl';
-import Linkify from 'react-linkify';
-
-
-const messages = defineMessages({
-  req_no_text: {
-    id: "hubs.widgets.halp.req_no_text",
-    defaultMessage: "(no message)"
-  },
-});
-
-
-export default function getRequestView(request, openCallback) {
-    var openButton = "";
-    if (openCallback) {
-      openButton = (
-        <div className="media-right media-middle">
-          <button className="btn btn-secondary btn-sm" onClick={openCallback}>
-            <span className="fa fa-chevron-right" aria-hidden="true"></span>
-          </button>
-        </div>
-      );
-    }
-    return (
-      <div className="request row">
-        <div className="hub col-sm-6">
-          <span>@{request.hub}</span>
-        </div>
-        <div className="channel col-sm-6">
-          {request.channel}
-        </div>
-        <div className="text col-sm-12">
-          { request.text ?
-            <Linkify>"{request.text}"</Linkify>
-          :
-            <em><FormattedMessage {...messages.req_no_text } /></em>
-          }
-        </div>
-        { request.url ?
-        <div className="url col-sm-12">
-          <a href={request.url}>
-            {request.url.replace(/^https?:\/\//, '')}
-          </a>
-        </div>
-        : null }
-        <div className="author media col-sm-12">
-          <a
-            href={request.author.url} target="_blank"
-            className="d-flex mr-3"
-            >
-            <img className="square-32 img-circle"
-                 src={request.author.avatar} />
-          </a>
-          <div className="media-body align-self-center">
-            <a href={request.author.url}>
-              {request.author.name}
-            </a>
-          </div>
-          {openButton}
-        </div>
-      </div>
-    );
-}
-
-
-// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/widgets/halp/halp.css b/hubs/static/client/app/widgets/halp/halp.css
new file mode 100644
index 0000000..e533bcc
--- /dev/null
+++ b/hubs/static/client/app/widgets/halp/halp.css
@@ -0,0 +1,126 @@
+
+
+/*
+ * Widget halp
+ */
+
+.widget-halp {
+}
+.widget-halp .hubs {
+    color: gray;
+}
+.widget-halp .hubs .fa {
+    margin-right: 4px;
+}
+.widget-halp .hubs span.hub {
+    border: 1px solid gray;
+    border-radius: 2px;
+    background-color: #eee;
+    display: inline-block;
+    margin: 2px 3px 2px 0;
+    padding: 1px 2px;
+    font-size: 90%;
+    cursor: pointer;
+}
+.widget-halp .hubs span.hub.inactive {
+    background-color: white;
+}
+.widget-halp .Spinner {
+    margin: 0 auto;
+}
+.widget-halp .requests {
+}
+.widget-halp .requests .no-request {
+    margin-top: 10px;
+}
+.widget-halp .requests .date {
+    text-transform: uppercase;
+    font-size: 110%;
+    margin-top: 10px;
+}
+.widget-halp .request {
+    background-color: #f5f5f5;
+    padding: 1em 0;
+    margin-top: 5px;
+}
+.widget-halp .request .hub span {
+    float: left;
+    font-weight: bold;
+    color: white;
+    background-color: #5bc0de;
+    border-radius: 10px;
+    padding: 2px 4px;
+}
+.widget-halp .request .channel {
+    color: gray;
+    text-align: right;
+}
+.widget-halp .request .text {
+    margin-top: 10px;
+}
+.widget-halp .request .url {
+    margin-top: 5px;
+    margin-bottom: 15px;
+}
+.widget-halp .request .author {
+}
+.widget-halp .request .open {
+    text-align: right;
+}
+.widget-halp .request .open button {
+    background-color: white;
+    border-color: gray;
+}
+.widget-halp .request .open button:hover {
+    background-color: #ccc;
+}
+.widget-halp .zoomed-request ol>li {
+    margin-top: 1em;
+}
+.widget-halp .view-more {
+    margin-top: 2em;
+    text-align: center;
+}
+.widget-halp .view-more a {
+    color: gray;
+}
+.widget-halp .ModalAllRequests .SpinnerCircle {
+    display: inline-block;
+    vertical-align: middle;
+}
+
+
+
+/*
+ * Config panel
+ */
+
+.widget-halp-config .hubs-list h5 {
+  font-weight: bold;
+}
+.widget-halp-config .hubs-list ul {
+  color: gray;
+}
+.widget-halp-config .hubs-list .hub {
+  border: 1px solid #aaa;
+  border-radius: 4px;
+  display: inline-block;
+  margin: 0.7em 0.5em 0 0;
+  padding: 1px 4px;
+  color: #aaa;
+}
+.widget-halp-config .hubs-list .remove-hub {
+  cursor: pointer;
+  font-weight: bold;
+}
+.widget-halp-config .form-side {
+  border-left: 1px solid #aaa;
+}
+.widget-halp-config h4 {
+  font-weight: bold;
+  margin-bottom: 1em;
+}
+.widget-halp-config fieldset {
+  margin-top: 1.5em;
+}
+
diff --git a/hubs/static/client/package.json b/hubs/static/client/package.json
index 553d1c1..d6e4a1b 100644
--- a/hubs/static/client/package.json
+++ b/hubs/static/client/package.json
@@ -7,6 +7,8 @@
     "doc": "docs"
   },
   "dependencies": {
+    "babel-polyfill": "^6.26.0",
+    "isomorphic-fetch": "^2.2.1",
     "prop-types": "^15.5.10",
     "react": "^15.6.1",
     "react-autosuggest": "^9.3.2",
@@ -14,34 +16,58 @@
     "react-intl": "^2.3.0",
     "react-linkify": "^0.2.1",
     "react-loadable": "^4.0.4",
+    "react-redux": "^5.0.6",
+    "react-sortable-hoc": "^0.6.7",
     "react-timeago": "^3.4.3",
-    "reconnecting-eventsource": "^1.0.1"
+    "reconnecting-eventsource": "^1.0.1",
+    "redux": "^3.7.2",
+    "redux-thunk": "^2.2.0"
   },
   "devDependencies": {
     "babel-core": "^6.26.0",
     "babel-eslint": "^7.2.3",
     "babel-jest": "^20.0.3",
     "babel-loader": "^7.1.1",
+    "babel-plugin-dynamic-import-node": "^1.1.0",
     "babel-plugin-syntax-dynamic-import": "^6.18.0",
+    "babel-plugin-transform-object-rest-spread": "^6.26.0",
     "babel-preset-es2015": "^6.24.1",
     "babel-preset-react": "^6.24.1",
+    "clean-webpack-plugin": "^0.1.17",
     "css-loader": "^0.28.4",
+    "enzyme": "^3.0.0",
+    "enzyme-adapter-react-15": "^1.0.0",
     "eslint": "^4.4.1",
     "eslint-loader": "^1.9.0",
     "eslint-plugin-react": "^7.2.1",
+    "extract-text-webpack-plugin": "^3.0.0",
     "file-loader": "^0.11.2",
     "jest": "^20.0.4",
     "react-test-renderer": "^15.6.1",
+    "redux-devtools": "^3.4.0",
     "style-loader": "^0.18.2",
-    "webpack": "^3.5.5"
+    "uglifyjs-webpack-plugin": "^0.4.6",
+    "webpack": "^3.5.5",
+    "webpack-merge": "^4.1.0"
   },
   "scripts": {
-    "dev": "webpack --watch",
-    "build": "webpack -p",
+    "dev": "webpack --watch --config webpack.dev.js",
+    "build": "webpack -p --config webpack.prod.js",
     "lint": "eslint app/*",
     "test": "jest"
   },
   "jest": {
+    "moduleFileExtensions": [
+      "js",
+      "jsx"
+    ],
+    "moduleDirectories": [
+      "node_modules"
+    ],
+    "moduleNameMapper": {
+      "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
+      "\\.(css|less)$": "<rootDir>/__mocks__/styleMock.js"
+    },
     "unmockedModulePathPatterns": [
       "<rootDir>/node_modules/react/",
       "<rootDir>/node_modules/react-dom/",
@@ -49,8 +75,11 @@
       "<rootDir>/node_modules/react-addons-test-utils/",
       "<rootDir>/node_modules/react-intl/",
       "<rootDir>/node_modules/intl-messageformat/"
+    ],
+    "setupFiles": [
+      "<rootDir>/setupTests.js"
     ]
   },
-  "author": "Eric Barbour <ebarbour@redhat.com>",
+  "author": "Aurelien Bompard <abompard@fedoraproject.org>",
   "license": "AGPL-3.0"
 }
diff --git a/hubs/static/client/setupTests.js b/hubs/static/client/setupTests.js
new file mode 100644
index 0000000..d74d0e0
--- /dev/null
+++ b/hubs/static/client/setupTests.js
@@ -0,0 +1,4 @@
+import Enzyme from 'enzyme';
+import Adapter from 'enzyme-adapter-react-15';
+
+Enzyme.configure({ adapter: new Adapter() });
diff --git a/hubs/static/client/webpack.common.js b/hubs/static/client/webpack.common.js
new file mode 100644
index 0000000..e060cd7
--- /dev/null
+++ b/hubs/static/client/webpack.common.js
@@ -0,0 +1,61 @@
+const webpack = require('webpack');
+const path = require('path');
+const CleanWebpackPlugin = require('clean-webpack-plugin');
+
+const PATHS = {
+  app: path.join(__dirname, 'app'),
+  build: path.join(__dirname, '..', 'js', 'build')
+};
+
+module.exports = {
+  entry: {
+    Hubs: path.join(PATHS.app, 'core', 'index.js'),
+  },
+  output: {
+    path: PATHS.build,
+    filename: '[name].js',
+    chunkFilename: '[name].[chunkhash:8].bundle.js',
+    publicPath: "/static/js/build/", // TODO: https://webpack.js.org/guides/public-path/
+    library: 'Hubs',
+  },
+  externals: {
+    'jquery': 'jQuery',
+    'react': 'React',
+    'react-dom': 'ReactDOM',
+  },
+  module: {
+    rules: [
+    {
+      test: /\.jsx?$/,
+      enforce: 'pre',
+      loader: 'eslint-loader',
+      options: {
+        emitWarning: true,
+      },
+      include : PATHS.app
+    },
+    {
+      test : /\.jsx?$/,
+      loader : 'babel-loader',
+      options: {
+        cacheDirectory: true,
+      },
+      exclude: /node_modules/,
+      include : PATHS.app
+    },
+    {
+      test: /\.(png|svg|jpg|gif)$/,
+      use: [ 'file-loader' ]
+    }
+    ]
+  },
+  plugins: [
+    new CleanWebpackPlugin(
+      [PATHS.build], {
+        allowExternal: true,
+        exclude: ['.gitignore']
+      }
+    ),
+    new webpack.optimize.CommonsChunkPlugin("common")
+  ]
+};
diff --git a/hubs/static/client/webpack.config.js b/hubs/static/client/webpack.config.js
deleted file mode 100644
index c308851..0000000
--- a/hubs/static/client/webpack.config.js
+++ /dev/null
@@ -1,63 +0,0 @@
-const webpack = require('webpack');
-const path = require('path');
-
-const PATHS = {
-  app: path.join(__dirname, 'app'),
-  build: path.join(__dirname, '..', 'js', 'build')
-};
-
-const config = {
-  entry: {
-    Hubs: path.join(PATHS.app, 'core', 'Hubs.js'),
-    Feed: path.join(PATHS.app, 'widgets', 'feed', 'Feed.js'),
-    Halp: path.join(PATHS.app, 'widgets', 'halp', 'Halp.js')
-  },
-  output: {
-    path: PATHS.build,
-    filename: '[name].js',
-    library: '[name]',
-  },
-  externals: {
-    'jquery': 'jQuery',
-    'react': 'React',
-    'react-dom': 'ReactDOM',
-  },
-  module: {
-    rules: [
-    {
-      test: /\.jsx?$/,
-      enforce: 'pre',
-      loader: 'eslint-loader',
-      options: {
-        emitWarning: true,
-      },
-      include : PATHS.app
-    },
-    {
-      test : /\.jsx?$/,
-      loader : 'babel-loader',
-      options: {
-        cacheDirectory: true,
-      },
-      exclude: /node_modules/,
-      include : PATHS.app
-    },
-    {
-      test: /\.css$/,
-      use: [
-        'style-loader',
-        'css-loader'
-      ]
-    },
-    {
-      test: /\.(png|svg|jpg|gif)$/,
-      use: [ 'file-loader' ]
-    }
-    ]
-  },
-  plugins: [
-    new webpack.optimize.CommonsChunkPlugin("common")
-  ]
-};
-
-module.exports = config;
diff --git a/hubs/static/client/webpack.dev.js b/hubs/static/client/webpack.dev.js
new file mode 100644
index 0000000..01164e2
--- /dev/null
+++ b/hubs/static/client/webpack.dev.js
@@ -0,0 +1,17 @@
+const merge = require('webpack-merge');
+const common = require('./webpack.common.js');
+
+module.exports = merge(common, {
+  devtool: 'inline-source-map',
+  module: {
+    rules: [
+    {
+      test: /\.css$/,
+      use: [
+        'style-loader',
+        'css-loader'
+      ]
+    }
+    ]
+  },
+});
diff --git a/hubs/static/client/webpack.prod.js b/hubs/static/client/webpack.prod.js
new file mode 100644
index 0000000..5db728b
--- /dev/null
+++ b/hubs/static/client/webpack.prod.js
@@ -0,0 +1,34 @@
+const webpack = require('webpack');
+const merge = require('webpack-merge');
+const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
+const ExtractTextPlugin = require("extract-text-webpack-plugin");
+const common = require('./webpack.common.js');
+
+module.exports = merge(common, {
+  devtool: 'source-map',
+  module: {
+    rules: [
+    {
+      test: /\.css$/,
+      use: ExtractTextPlugin.extract({
+        fallback: "style-loader",
+        use: "css-loader"
+      })
+    }
+    ]
+  },
+  plugins: [
+    new ExtractTextPlugin({
+      filename: "style.css",
+      allChunks: true,
+    }),
+    new UglifyJSPlugin({
+      sourceMap: true
+    }),
+    new webpack.DefinePlugin({
+      'process.env': {
+        'NODE_ENV': JSON.stringify('production')
+      }
+    })
+  ]
+})
diff --git a/hubs/static/css/style.css b/hubs/static/css/style.css
index d19cc3e..8d77ee2 100644
--- a/hubs/static/css/style.css
+++ b/hubs/static/css/style.css
@@ -128,94 +128,11 @@ header span.edit {
   outline: 0;
 }
 
-  .loading {
-      width: 50px;
-      height: 10px;
-      background: url("../img/spinner.gif") no-repeat center center;
-      vertical-align: bottom;
-      display: none;  /* hidden by default */
-  }
-
-.loading-circle {
-  width: 32px;
-  height: 32px;
-  line-height: 32px;
-  background: url("../img/spinner-circle.gif") no-repeat center center;
-  vertical-align: bottom;
-  display: none;  /* hidden by default */
-}
-
-
-
-/*
- * Settings
- */
-
-#settings-modal .modal-title {
-  line-height: 1;
-}
-#settings-modal .nav {
-  border: 0;
-  background-color: #eee;
-}
-#settings-modal .nav-tabs .nav-item {
-  float: none;
-  margin-bottom: 0;
-}
-#settings-modal .nav-tabs .nav-link {
-  border: 0;
-}
-#settings-modal .modal-body h3 {
-  margin-bottom: 1em;
-}
-#settings-modal .modal-body label {
-  font-size: 115%;
-}
-#settings-modal .modal-body .tab-content {
-  padding: 1em;
-  padding-bottom: 2em;
-}
-
-@media (min-width: 768px) {
-  /* the menu is on the left */
-  #settings-modal .nav .nav-item {
-    margin-bottom: 0;
-  }
-  #settings-modal .nav .nav-link {
-    border-radius: 0.25rem 0 0 0.25rem;
-  }
-}
-
 
 /*
  * Widgets
  */
 
-.widget {
-  font-family: 'Open Sans', sans-serif;
-  margin-bottom: .5rem;
-}
-
-.widget .card {
-  width: 100%;
-}
-
-.widget-buttons {
-  font-size: 10px;
-  position: absolute;
-  top: 0;
-  right: 0;
-  padding: .3rem;
-}
-
-.widget .widget-buttons a {
-  margin-right: .4rem;
-  color: #373a3c;
-}
-.widget .widget-buttons a:hover {
-  text-decoration: none;
-}
-
 /* hub widgets */
 .rules-table {
   width: 180px
@@ -288,27 +205,6 @@ font-size: 32pt;
 .navbar-nav > li.idle-2years  > a { color: #BBB; }
 .navbar-nav > li.idle-5years  > a { color: #CCC; }
 
-#vertical-navbar {
-  background-color: #fcfcfc;
-  border: 1px solid #dddddd;
-}
-@media (min-width: 992px) {
-  #vertical-navbar {
-    position: relative;
-    top: -85px;
-  }
-}
-
-#vertical-navbar a.nav-link {
-  color: #373a3c;
-  font-size: 85%;
-}
-
-#vertical-navbar a.nav-link.active {
-  color: white;
-  /*background-color: #373a3c;*/
-}
-
 
 
 
@@ -395,11 +291,6 @@ font-size: 32pt;
   border-bottom: none !important;
 }
 
-.modal-footer button {
-  float: right;
-  margin-left: 1em;
-}
-
 .p-abs {
   position: absolute;
   bottom: 0;
@@ -420,168 +311,16 @@ font-size: 32pt;
 }
 
 
-/*
- * Autosuggest
- */
-.completion-input {
-  position: relative;
-}
-.react-autosuggest__suggestions-container {
-  position: absolute;
-  width: 100%;
-  z-index: 10;
-}
-.react-autosuggest__suggestions-list {
-  list-style: none;
-  padding: 0.3em 0.8em;
-  background-color: #fafafa;
-  border-bottom-left-radius: .25rem;
-  border-bottom-right-radius: .25rem;
-  border: 1px solid #ccc;
-  border-top: none;
-}
-.react-autosuggest__suggestion {
-  cursor: pointer;
-}
-.react-autosuggest__container--open input {
-  border-bottom-left-radius: 0px;
-  border-bottom-right-radius: 0px;
-}
-
-.completion-input .loading-circle {
-  position: absolute;
-  top: 0;
-  right: 0;
-  z-index: 2;
-}
-
-
-
-/*
- * Widget halp
- */
-.widget-halp {
-}
-.widget-halp .hubs {
-    color: gray;
-}
-.widget-halp .hubs .fa {
-    margin-right: 4px;
-}
-.widget-halp .hubs span.hub {
-    border: 1px solid gray;
-    border-radius: 2px;
-    background-color: #eee;
-    display: inline-block;
-    margin: 2px 3px 2px 0;
-    padding: 1px 2px;
-    font-size: 90%;
-    cursor: pointer;
-}
-.widget-halp .hubs span.hub.inactive {
-    background-color: white;
-}
-.widget-halp .requests {
-}
-.widget-halp .requests .no-request {
-    margin-top: 10px;
-}
-.widget-halp .requests .date {
-    text-transform: uppercase;
-    font-size: 110%;
-    margin-top: 10px;
-}
-.widget-halp .request {
-    background-color: #f5f5f5;
-    padding: 1em 0;
-    margin-top: 5px;
-}
-.widget-halp .request .hub span {
-    float: left;
-    font-weight: bold;
-    color: white;
-    background-color: #5bc0de;
-    border-radius: 10px;
-    padding: 2px 4px;
-}
-.widget-halp .request .channel {
-    color: gray;
-    text-align: right;
-}
-.widget-halp .request .text {
-    margin-top: 10px;
-}
-.widget-halp .request .url {
-    margin-top: 5px;
-    margin-bottom: 15px;
-}
-.widget-halp .request .author {
-}
-.widget-halp .request .open {
-    text-align: right;
-}
-.widget-halp .request .open button {
-    background-color: white;
-    border-color: gray;
-}
-.widget-halp .request .open button:hover {
-    background-color: #ccc;
-}
-.widget-halp .zoomed-request ol>li {
-    margin-top: 1em;
-}
-.widget-halp .view-more {
-    margin-top: 2em;
-    text-align: center;
-}
-.widget-halp .view-more a {
-    color: gray;
-}
-
-/* Config panel */
-.widget-halp-config .modal-body {
-  padding-top: 1em;
-}
-.widget-halp-config .hubs-list h5 {
-  font-weight: bold;
-}
-.widget-halp-config .hubs-list ul {
-  color: gray;
-}
-.widget-halp-config .hubs-list .hub {
-  border: 1px solid #aaa;
-  border-radius: 4px;
-  display: inline-block;
-  margin: 0.7em 0.5em 0 0;
-  padding: 1px 4px;
-  color: #aaa;
-}
-.widget-halp-config .hubs-list .remove-hub {
-  cursor: pointer;
-  font-weight: bold;
-}
-.widget-halp-config .modal-body .form-side {
-  border-left: 1px solid #aaa;
-}
-.widget-halp-config .modal-body h4 {
-  font-weight: bold;
-  margin-bottom: 1em;
-}
-.widget-halp-config fieldset {
-  margin-top: 1.5em;
-}
-
 
 /*
  *  Contact Widget
  */
- 
- 
+
 .widget-contact .card-block ul {
   padding: 0px; 
 }
 
-.widget-contact i {
+.widget-contact .card-block i {
   width: 25px;
   text-align: center;
 }
@@ -590,7 +329,7 @@ font-size: 32pt;
 /*
  * Issue Widgets
  */
- 
+
 .widget .widget-pagure_pr li,
 .widget .widget-githubissues li,
 .widget .widget-bugzilla li {
@@ -603,16 +342,6 @@ font-size: 32pt;
 border: 0}
 
 
-/*
- * Widget Feed
- */
-
-.component-feed .media {
-  border-bottom: 1px solid rgba(0, 0, 0, .125);
-}
-.component-feed .media:last-child {
-  border: 0
-}
 
 
 /** fedora bootstrap overrides **/
@@ -665,8 +394,7 @@ header h5.m-b-1 {
 }
 
 
-#right_widgets .widget-contact .card-block {
-  padding-top: 1rem !important;
+#right_widgets .widget-contact .contactinfo-container {
   font-size: 80%;
 }
 
diff --git a/hubs/templates/master.html b/hubs/templates/master.html
index 3e65d31..98efe34 100644
--- a/hubs/templates/master.html
+++ b/hubs/templates/master.html
@@ -6,6 +6,7 @@
   <link href="{{ url_for('static', filename='fedora-bootstrap/fedora-bootstrap.min.css') }}" rel="stylesheet" />
   <link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet" />
   <link href="{{ url_for('static', filename='css/pace-theme-minimal.css') }}" rel="stylesheet" />
+  <link href="{{ url_for('static', filename='js/build/style.css') }}" rel="stylesheet" />
   <script type="text/javascript"  src="{{ url_for('static', filename='js/pace.min.js') }}"></script>
   <script type="text/javascript"  src="{{ url_for('static', filename='notificon.js') }}"></script>
   <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.6.1/css/font-awesome.min.css">
diff --git a/tox.ini b/tox.ini
index a6609a0..c7e54cf 100644
--- a/tox.ini
+++ b/tox.ini
@@ -52,4 +52,4 @@ commands =
 
 [flake8]
 show-source = True
-exclude = .git,.tox,dist,*egg,doc
+exclude = .git,.tox,dist,*egg,doc,node_modules

From 27779006a2b6826a24a920ffd7ec489358a17ed0 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Oct 06 2017 16:30:51 +0000
Subject: [PATCH 18/20] Various minor refactoring


---

diff --git a/hubs/static/client/.babelrc b/hubs/static/client/.babelrc
index aa30d85..bfa2dab 100644
--- a/hubs/static/client/.babelrc
+++ b/hubs/static/client/.babelrc
@@ -1,9 +1,16 @@
 {
   "presets" : [["es2015", {"modules": false}], "react"],
-  "plugins": ["syntax-dynamic-import", "transform-object-rest-spread"],
+  "plugins": [
+    "syntax-dynamic-import",
+    "transform-object-rest-spread"
+  ],
   "env": {
     "test": {
-      "plugins": ["transform-es2015-modules-commonjs", "dynamic-import-node", "transform-object-rest-spread"]
+      "plugins": [
+        "transform-es2015-modules-commonjs",
+        "dynamic-import-node",
+        "transform-object-rest-spread",
+      ]
     }
   }
 }
diff --git a/hubs/static/client/app/core/App.js b/hubs/static/client/app/core/App.js
index 6c90b23..2fb30fc 100644
--- a/hubs/static/client/app/core/App.js
+++ b/hubs/static/client/app/core/App.js
@@ -1,32 +1,14 @@
 import React from 'react';
-import {
-  connect,
-  Provider
-  } from 'react-redux';
+import { Provider } from 'react-redux';
 import { IntlProvider } from 'react-intl';
-import * as Pages from "../core/Pages";
+import PageContainer from "./PageContainer";
 
 
-const PageContainer = connect(
-  (state) => ({pageName: state.ui.page})
-)(
-  (props) => {
-    if (!Pages[props.pageName]) {
-      console.error("No such page: " + props.pageName + ". Choose one of:", Object.keys(Pages).join(", "));
-      return null;
-    }
-    return React.createElement(Pages[props.pageName], {});
-  }
+const App = (props) => (
+  <Provider store={props.store}>
+    <IntlProvider locale={navigator.language}>
+      <PageContainer />
+    </IntlProvider>
+  </Provider>
 );
-
-
-const App = (props) => {
-  return (
-    <Provider store={props.store}>
-      <IntlProvider locale={navigator.language}>
-        <PageContainer />
-      </IntlProvider>
-    </Provider>
-  );
-}
 export default App;
diff --git a/hubs/static/client/app/core/PageContainer.js b/hubs/static/client/app/core/PageContainer.js
new file mode 100644
index 0000000..ddb5bf0
--- /dev/null
+++ b/hubs/static/client/app/core/PageContainer.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import * as Pages from "./Pages";
+
+
+const PageContainer = (props) => {
+  const Page = Pages[props.pageName];
+  if (!Page) {
+    throw new Error(
+      "No such page: " + props.pageName
+      + ". Choose one of:",
+      Object.keys(Pages).join(", ")
+    );
+  }
+  return (
+    <Page />
+  );
+}
+
+export default connect(
+  (state) => ({pageName: state.ui.page})
+)(
+  PageContainer
+);
diff --git a/hubs/static/client/app/core/index.js b/hubs/static/client/app/core/index.js
deleted file mode 100644
index a6cbb81..0000000
--- a/hubs/static/client/app/core/index.js
+++ /dev/null
@@ -1,27 +0,0 @@
-/* Add the ES6 Promise polyfill because all browsers don't have it yet. */
-import 'babel-polyfill';
-
-import React from 'react';
-import ReactDOM from 'react-dom';
-import { createStore, applyMiddleware } from 'redux';
-import thunk from 'redux-thunk';
-import rootReducer from './reducers';
-import App from "./App";
-
-
-export function setupPage(elementId, initialState) {
-
-  const store = createStore(
-    rootReducer,
-    initialState,
-    applyMiddleware(thunk)
-  );
-  //store.subscribe(() =>
-  //  console.log(store.getState())
-  //)
-  ReactDOM.render(
-    <App store={store} />,
-    document.getElementById(elementId)
-    );
-
-}
diff --git a/hubs/static/client/app/core/store.js b/hubs/static/client/app/core/store.js
new file mode 100644
index 0000000..f04ec7f
--- /dev/null
+++ b/hubs/static/client/app/core/store.js
@@ -0,0 +1,21 @@
+import { createStore, applyMiddleware } from 'redux';
+import thunk from 'redux-thunk';
+import rootReducer from './reducers';
+
+
+let middleware = [
+  thunk
+]
+
+if (process.env.NODE_ENV === "development") {
+  const { logger } = require("redux-logger");
+  middleware.push(logger);
+}
+
+export default function getStore(initialState) {
+  return createStore(
+    rootReducer,
+    initialState,
+    applyMiddleware(...middleware)
+  );
+}
diff --git a/hubs/static/client/app/index.js b/hubs/static/client/app/index.js
new file mode 100644
index 0000000..f1c3824
--- /dev/null
+++ b/hubs/static/client/app/index.js
@@ -0,0 +1,13 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import getStore from "./core/store";
+import App from "./core/App";
+
+
+export function setupPage(elementId, initialState) {
+  const store = getStore(initialState);
+  ReactDOM.render(
+    <App store={store} />,
+    document.getElementById(elementId)
+  );
+}
diff --git a/hubs/static/client/package.json b/hubs/static/client/package.json
index d6e4a1b..c2b0fc5 100644
--- a/hubs/static/client/package.json
+++ b/hubs/static/client/package.json
@@ -45,6 +45,7 @@
     "jest": "^20.0.4",
     "react-test-renderer": "^15.6.1",
     "redux-devtools": "^3.4.0",
+    "redux-logger": "^3.0.6",
     "style-loader": "^0.18.2",
     "uglifyjs-webpack-plugin": "^0.4.6",
     "webpack": "^3.5.5",
diff --git a/hubs/static/client/webpack.common.js b/hubs/static/client/webpack.common.js
index e060cd7..cdf4140 100644
--- a/hubs/static/client/webpack.common.js
+++ b/hubs/static/client/webpack.common.js
@@ -3,59 +3,63 @@ const path = require('path');
 const CleanWebpackPlugin = require('clean-webpack-plugin');
 
 const PATHS = {
-  app: path.join(__dirname, 'app'),
-  build: path.join(__dirname, '..', 'js', 'build')
+  app: path.resolve(path.join(__dirname, 'app')),
+  build: path.resolve(path.join(__dirname, '..', 'js', 'build'))
 };
 
 module.exports = {
-  entry: {
-    Hubs: path.join(PATHS.app, 'core', 'index.js'),
-  },
-  output: {
-    path: PATHS.build,
-    filename: '[name].js',
-    chunkFilename: '[name].[chunkhash:8].bundle.js',
-    publicPath: "/static/js/build/", // TODO: https://webpack.js.org/guides/public-path/
-    library: 'Hubs',
-  },
-  externals: {
-    'jquery': 'jQuery',
-    'react': 'React',
-    'react-dom': 'ReactDOM',
-  },
-  module: {
-    rules: [
-    {
-      test: /\.jsx?$/,
-      enforce: 'pre',
-      loader: 'eslint-loader',
-      options: {
-        emitWarning: true,
-      },
-      include : PATHS.app
+  paths: PATHS,
+  config: {
+    entry: [
+      'babel-polyfill',
+      path.join(PATHS.app, 'index.js')
+    ],
+    output: {
+      path: PATHS.build,
+      filename: '[name].js',
+      chunkFilename: '[name].[chunkhash:8].bundle.js',
+      publicPath: "/static/js/build/", // TODO: https://webpack.js.org/guides/public-path/
+      library: 'Hubs',
+    },
+    externals: {
+      'jquery': 'jQuery',
+      'react': 'React',
+      'react-dom': 'ReactDOM',
     },
-    {
-      test : /\.jsx?$/,
-      loader : 'babel-loader',
-      options: {
-        cacheDirectory: true,
+    module: {
+      rules: [
+      {
+        test: /\.jsx?$/,
+        enforce: 'pre',
+        loader: 'eslint-loader',
+        options: {
+          emitWarning: true,
+        },
+        include : PATHS.app
+      },
+      {
+        test : /\.jsx?$/,
+        loader : 'babel-loader',
+        options: {
+          cacheDirectory: true,
+        },
+        exclude: /node_modules/,
+        include : PATHS.app
       },
-      exclude: /node_modules/,
-      include : PATHS.app
+      {
+        test: /\.(png|svg|jpg|gif)$/,
+        use: [ 'file-loader' ]
+      }
+      ]
     },
-    {
-      test: /\.(png|svg|jpg|gif)$/,
-      use: [ 'file-loader' ]
-    }
+    plugins: [
+      new CleanWebpackPlugin(
+        [PATHS.build], {
+          allowExternal: true,
+          exclude: ['.gitignore']
+        }
+      ),
+      new webpack.optimize.CommonsChunkPlugin("common"),
     ]
-  },
-  plugins: [
-    new CleanWebpackPlugin(
-      [PATHS.build], {
-        allowExternal: true,
-        exclude: ['.gitignore']
-      }
-    ),
-    new webpack.optimize.CommonsChunkPlugin("common")
-  ]
+  }
 };
diff --git a/hubs/static/client/webpack.dev.js b/hubs/static/client/webpack.dev.js
index 01164e2..56efa9e 100644
--- a/hubs/static/client/webpack.dev.js
+++ b/hubs/static/client/webpack.dev.js
@@ -1,7 +1,8 @@
+const webpack = require('webpack');
 const merge = require('webpack-merge');
 const common = require('./webpack.common.js');
 
-module.exports = merge(common, {
+module.exports = merge(common.config, {
   devtool: 'inline-source-map',
   module: {
     rules: [
@@ -14,4 +15,11 @@ module.exports = merge(common, {
     }
     ]
   },
+  plugins: [
+    new webpack.DefinePlugin({
+      'process.env': {
+        'NODE_ENV': JSON.stringify('development')
+      }
+    }),
+  ]
 });
diff --git a/hubs/static/client/webpack.prod.js b/hubs/static/client/webpack.prod.js
index 5db728b..2784ad9 100644
--- a/hubs/static/client/webpack.prod.js
+++ b/hubs/static/client/webpack.prod.js
@@ -23,12 +23,8 @@ module.exports = merge(common, {
       allChunks: true,
     }),
     new UglifyJSPlugin({
-      sourceMap: true
+      sourceMap: true,
+      comments: false
     }),
-    new webpack.DefinePlugin({
-      'process.env': {
-        'NODE_ENV': JSON.stringify('production')
-      }
-    })
   ]
 })
diff --git a/hubs/templates/master.html b/hubs/templates/master.html
index 98efe34..a0f8b8c 100644
--- a/hubs/templates/master.html
+++ b/hubs/templates/master.html
@@ -167,7 +167,7 @@
 <script src="https://unpkg.com/react@15/dist/react{% if not config.DEBUG %}.min{% endif %}.js"></script>
 <script src="https://unpkg.com/react-dom@15/dist/react-dom{% if not config.DEBUG %}.min{% endif %}.js"></script>
 <script src="{{ url_for('static', filename='js/build/common.js') }}"></script>
-<script src="{{ url_for('static', filename='js/build/Hubs.js') }}"></script>
+<script src="{{ url_for('static', filename='js/build/main.js') }}"></script>
 {% endblock %}
 
 </body>

From e36fdb54aede63f8de4eecee672d1b88798ee107 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Oct 06 2017 16:31:05 +0000
Subject: [PATCH 19/20] Inform the JS UI of the public build path


---

diff --git a/hubs/static/client/app/index.js b/hubs/static/client/app/index.js
index f1c3824..cccae63 100644
--- a/hubs/static/client/app/index.js
+++ b/hubs/static/client/app/index.js
@@ -1,3 +1,7 @@
+// Read the public path from the backend
+// https://webpack.js.org/guides/public-path/
+__webpack_public_path__ = window.resourceBaseUrl;
+
 import React from 'react';
 import ReactDOM from 'react-dom';
 import getStore from "./core/store";
diff --git a/hubs/static/client/webpack.common.js b/hubs/static/client/webpack.common.js
index cdf4140..1f8308f 100644
--- a/hubs/static/client/webpack.common.js
+++ b/hubs/static/client/webpack.common.js
@@ -18,7 +18,6 @@ module.exports = {
       path: PATHS.build,
       filename: '[name].js',
       chunkFilename: '[name].[chunkhash:8].bundle.js',
-      publicPath: "/static/js/build/", // TODO: https://webpack.js.org/guides/public-path/
       library: 'Hubs',
     },
     externals: {
diff --git a/hubs/templates/master.html b/hubs/templates/master.html
index a0f8b8c..cbdab08 100644
--- a/hubs/templates/master.html
+++ b/hubs/templates/master.html
@@ -166,6 +166,10 @@
 <script src="{{ url_for('static', filename='fedora-bootstrap/fedora-bootstrap.min.js') }}"></script>
 <script src="https://unpkg.com/react@15/dist/react{% if not config.DEBUG %}.min{% endif %}.js"></script>
 <script src="https://unpkg.com/react-dom@15/dist/react-dom{% if not config.DEBUG %}.min{% endif %}.js"></script>
+<script>
+{# Inform the JS UI of the public build path. It *must* end with a slash. #}
+window.resourceBaseUrl = {{ url_for('static', filename='js/build/') | tojson }};
+</script>
 <script src="{{ url_for('static', filename='js/build/common.js') }}"></script>
 <script src="{{ url_for('static', filename='js/build/main.js') }}"></script>
 {% endblock %}

From 4259cf05e927b47b2f392ef5f11c8dadc7ba0d69 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Oct 06 2017 16:31:14 +0000
Subject: [PATCH 20/20] Add JS lint to tox


---

diff --git a/tox.ini b/tox.ini
index c7e54cf..74cc729 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = py{27,34,35}-flask01{0,1,2},js,docs,lint
+envlist = py{27,34,35}-flask01{0,1,2},js,jslint,docs,lint
 skipsdist = True
 
 
@@ -31,6 +31,17 @@ commands =
     npm install
     npm test
 
+[testenv:jslint]
+skip_install = True
+usedevelop = False
+deps =
+whitelist_externals =
+    npm
+changedir = {toxinidir}/hubs/static/client
+commands =
+    npm install
+    npm run lint
+
 [testenv:docs]
 changedir = docs
 deps =

