From 68f92a71f78750f2393c3bff2e2cdedb9c06da45 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Mar 21 2017 09:44:19 +0000
Subject: [PATCH 1/6] Add Halp widget


---

diff --git a/hubs/default_config.py b/hubs/default_config.py
index 5837486..72cda31 100644
--- a/hubs/default_config.py
+++ b/hubs/default_config.py
@@ -44,6 +44,7 @@ CHAT_NETWORKS = [
     }
 ]
 
+DATAGREPPER_URI = 'https://apps.fedoraproject.org/datagrepper'
 
 WIDGETS = [
     'hubs.widgets.about:About',
@@ -58,6 +59,7 @@ WIDGETS = [
     'hubs.widgets.fhosted:FedoraHosted',
     'hubs.widgets.github_pr:GitHubPRs',
     'hubs.widgets.githubissues:GitHubIssues',
+    'hubs.widgets.halp:Halp',
     'hubs.widgets.meetings:Meetings',
     'hubs.widgets.memberships:Memberships',
     'hubs.widgets.pagure_pr:PagurePRs',
diff --git a/hubs/defaults.py b/hubs/defaults.py
index 2d27239..7bda776 100644
--- a/hubs/defaults.py
+++ b/hubs/defaults.py
@@ -82,6 +82,12 @@ def add_user_widgets(session, hub, username, fullname):
             'username': username,
         }))
     hub.widgets.append(widget)
+    hub.widgets.append(
+        hubs.models.Widget(
+            plugin='halp', index=9,
+            _config=json.dumps({
+            }))
+        )
     return hub
 
 
diff --git a/hubs/static/client/app/components/CompletionInput.jsx b/hubs/static/client/app/components/CompletionInput.jsx
new file mode 100644
index 0000000..9b8b581
--- /dev/null
+++ b/hubs/static/client/app/components/CompletionInput.jsx
@@ -0,0 +1,150 @@
+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.onSuggestionSelected = this.onSuggestionSelected.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
+    });
+  }
+
+  onSuggestionSelected(e, {suggestionValue}) {
+    // Transmit a "fake" changed event to the parent, if asked to.
+    if (!this.props.onChange) { return; }
+    const mockedEvent = {
+      type: 'change',
+      target: {
+        type: 'input',
+        name: this.props.name,
+        value: suggestionValue,
+      },
+    }
+    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) {
+        this.setState({
+          suggestions: data.results,
+          loading: false
+        });
+      }.bind(this),
+      error: function(xhr, status, err) {
+        console.error(this.props.url, status, err.toString());
+        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 = {
+      //placeholder: 'Type a programming language',
+      name: this.props.name,
+      className: "form-control",
+      value: this.state.value,
+      placeholder: this.props.placeholder,
+      onChange: this.onChange,
+    };
+
+    // Render the widget.
+    return (
+      <div className="completion-input">
+        <Autosuggest
+          suggestions={this.state.suggestions}
+          onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
+          onSuggestionsClearRequested={this.onSuggestionsClearRequested}
+          onSuggestionSelected={this.onSuggestionSelected}
+          getSuggestionValue={getSuggestionValue}
+          renderSuggestion={renderSuggestion}
+          inputProps={inputProps}
+        />
+        { this.state.loading ?
+          <span className="form-control-feedback">
+            <img src={this.props.spinner} />
+          </span>
+        : null }
+      </div>
+    );
+  }
+
+}
+CompletionInput.defaultProps = {
+    placeholder: "",
+    queryDelay: 800,
+};
+
+
+// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/components/Modal.jsx b/hubs/static/client/app/components/Modal.jsx
new file mode 100644
index 0000000..1ba31b1
--- /dev/null
+++ b/hubs/static/client/app/components/Modal.jsx
@@ -0,0 +1,109 @@
+import React from 'react';
+
+
+export default class Modal extends React.Component {
+
+  constructor(props) {
+    super(props);
+    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); }
+    $(this.domNode).modal("show");
+  }
+
+  componentWillUpdate(nextProps, nextState) {
+    if (!nextProps.open) { this.hide(); }
+  }
+
+  componentDidUpdate() {
+    if (this.props.open) { 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">
+              <button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+              <h4 className="modal-title">{this.props.title}</h4>
+            </div>
+            <div className="modal-body">
+              {this.getModalBody()}
+            </div>
+            {footer ?
+            <div className="modal-footer">
+              {footer}
+            </div>
+             : null}
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
+Modal.propTypes = {
+    open: React.PropTypes.bool,
+    data: React.PropTypes.object
+};
+
+
+// vim: set ts=2 sw=2 et:
diff --git a/hubs/static/client/app/widgets/halp/Halp.jsx b/hubs/static/client/app/widgets/halp/Halp.jsx
new file mode 100644
index 0000000..f1db852
--- /dev/null
+++ b/hubs/static/client/app/widgets/halp/Halp.jsx
@@ -0,0 +1,182 @@
+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 HalpWidget 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 ?
+              <img className="center-block" src={this.props.urls.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>
+          <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/Hub.jsx b/hubs/static/client/app/widgets/halp/Hub.jsx
new file mode 100644
index 0000000..044e6d9
--- /dev/null
+++ b/hubs/static/client/app/widgets/halp/Hub.jsx
@@ -0,0 +1,23 @@
+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.jsx b/hubs/static/client/app/widgets/halp/HubsList.jsx
new file mode 100644
index 0000000..3915dd4
--- /dev/null
+++ b/hubs/static/client/app/widgets/halp/HubsList.jsx
@@ -0,0 +1,43 @@
+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.jsx b/hubs/static/client/app/widgets/halp/ModalAllRequests.jsx
new file mode 100644
index 0000000..c3d13cd
--- /dev/null
+++ b/hubs/static/client/app/widgets/halp/ModalAllRequests.jsx
@@ -0,0 +1,234 @@
+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="row form-horizontal" method="post"
+              action={this.props.urls.search} onSubmit={this.onSubmit}>
+          <div className="form-group">
+            <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">
+            <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">
+            <label htmlFor="people" className="col-sm-4 control-label">
+              <FormattedMessage {...messages.search_people} />
+            </label>
+            <div className="col-sm-8">
+              <CompletionInput
+                name="people"
+                url={this.props.urls.requesters}
+                spinner={this.props.urls.spinnerCircle}
+                onChange={this.handleChange} />
+            </div>
+          </div>
+          <div className="form-group">
+            <div className="col-sm-offset-4 col-sm-8">
+              <button type="submit" className="btn btn-primary">
+                <FormattedMessage {...messages.search_button} />
+              </button>
+              { this.state.loading && <img style={{marginLeft: "2em"}} src={this.props.urls.spinner} /> }
+            </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.jsx b/hubs/static/client/app/widgets/halp/ModalRequest.jsx
new file mode 100644
index 0000000..b6d0860
--- /dev/null
+++ b/hubs/static/client/app/widgets/halp/ModalRequest.jsx
@@ -0,0 +1,99 @@
+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 style={{listStyleType: "none"}}>
+              <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 style={{listStyleType: "none"}}>
+              <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.jsx b/hubs/static/client/app/widgets/halp/Request.jsx
new file mode 100644
index 0000000..bc80c6c
--- /dev/null
+++ b/hubs/static/client/app/widgets/halp/Request.jsx
@@ -0,0 +1,40 @@
+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/getRequestView.jsx b/hubs/static/client/app/widgets/halp/getRequestView.jsx
new file mode 100644
index 0000000..3e0296f
--- /dev/null
+++ b/hubs/static/client/app/widgets/halp/getRequestView.jsx
@@ -0,0 +1,65 @@
+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">
+          <div className="media-left media-middle">
+            <a href={request.author.url} target="_blank">
+              <img className="media-object square-32 img-circle"
+                   src={request.author.avatar} />
+            </a>
+          </div>
+          <div className="media-body">
+            <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/package.json b/hubs/static/client/package.json
index 746d6c6..f237da9 100644
--- a/hubs/static/client/package.json
+++ b/hubs/static/client/package.json
@@ -14,6 +14,9 @@
     "react": "^15.4.0",
     "react-dom": "^15.4.0",
     "react-timeago": "^3.1.1",
+    "react-autosuggest": "~6.1.0",
+    "react-linkify": "^0.1.3",
+    "react-intl": "~2.1.5",
     "webpack": "~1.13.1"
   },
   "devDependencies": {
diff --git a/hubs/static/client/webpack.config.js b/hubs/static/client/webpack.config.js
index ba91ad0..4bd3fd9 100644
--- a/hubs/static/client/webpack.config.js
+++ b/hubs/static/client/webpack.config.js
@@ -10,6 +10,7 @@ const config = {
     entry: {
         Hubs: path.join(PATHS.app, 'core', 'Hubs.js'),
         Feed: path.join(PATHS.app, 'widgets', 'feed', 'Feed.jsx'),
+        Halp: path.join(PATHS.app, 'widgets', 'halp', 'Halp.jsx')
     },
     output: {
         path: PATHS.build,
diff --git a/hubs/static/css/style.css b/hubs/static/css/style.css
index ab51d60..7516bea 100644
--- a/hubs/static/css/style.css
+++ b/hubs/static/css/style.css
@@ -451,6 +451,137 @@ font-size: 32pt;
 }
 
 
+/*
+ * Autosuggest
+ */
+.react-autosuggest__suggestions-container {
+    position: absolute;
+    z-index: 10;
+    width: 100%;
+    padding-right: 2em;
+}
+.react-autosuggest__suggestions-container ul {
+    list-style: none;
+    padding: 0.3em 0.8em;
+    background-color: #eee;
+    border-bottom-left-radius: 4px;
+    border-bottom-right-radius: 4px;
+}
+
+.form-control-feedback {
+  position: absolute;
+  top: 0;
+  right: 0;
+  z-index: 2;
+  display: block;
+  width: 34px;
+  height: 34px;
+  line-height: 34px;
+  text-align: center;
+  pointer-events: none;
+}
+.input-lg + .form-control-feedback {
+  width: 46px;
+  height: 46px;
+  line-height: 46px;
+}
+.input-sm + .form-control-feedback {
+  width: 30px;
+  height: 30px;
+  line-height: 30px;
+}
+.form-horizontal .form-control-feedback {
+  right: 15px;
+}
+.form-inline .form-control-feedback {
+  top: 0;
+}
+
+
+
+/*
+ * 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;
+}
+
+
+
 /** fedora bootstrap overrides **/
 
 /** tightening up card headers - they are too fat imho **/
@@ -470,3 +601,7 @@ header h5.m-b-1 {
 .card .panel-footer {
   padding-bottom: 2rem; /* fixes request new meeting button padding on the upcoming meeting widget */
 }
+
+
+
+/* vim: set ts=2 sw=2 et: */
diff --git a/hubs/static/img/spinner-circle.gif b/hubs/static/img/spinner-circle.gif
new file mode 100644
index 0000000..d0bce15
Binary files /dev/null and b/hubs/static/img/spinner-circle.gif differ
diff --git a/hubs/tests/test_fedora_hubs_flask_api.py b/hubs/tests/test_fedora_hubs_flask_api.py
index c9e8c39..2c8f1ff 100644
--- a/hubs/tests/test_fedora_hubs_flask_api.py
+++ b/hubs/tests/test_fedora_hubs_flask_api.py
@@ -83,7 +83,7 @@ class HubsAPITest(hubs.tests.APPTest):
             "name": "ralph",
             "owners": ["ralph"],
             "subscribers": [],
-            "widgets": [34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 56]
+            "widgets": [37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 61],
         }
         self.assertDictEqual(data, json.loads(result.get_data(as_text=True)))
 
diff --git a/hubs/tests/test_models.py b/hubs/tests/test_models.py
index 66c1488..8669160 100644
--- a/hubs/tests/test_models.py
+++ b/hubs/tests/test_models.py
@@ -32,7 +32,7 @@ class ModelTest(hubs.tests.APPTest):
 
         # check if widgets still are intact
         widgets = hubs.models.Widget.by_hub_id_all(hub.name)
-        self.assertEqual(12, len(widgets))
+        self.assertEqual(13, len(widgets))
 
     def test_delete_hubs(self):
         # verify the hub exists
@@ -48,7 +48,7 @@ class ModelTest(hubs.tests.APPTest):
 
         # check if widgets exist
         widgets = hubs.models.Widget.by_hub_id_all(hub.name)
-        self.assertEqual(12, len(widgets))
+        self.assertEqual(13, len(widgets))
 
         # delete the hub
         self.session.delete(hub)
diff --git a/hubs/widgets/halp/__init__.py b/hubs/widgets/halp/__init__.py
new file mode 100644
index 0000000..92df5a4
--- /dev/null
+++ b/hubs/widgets/halp/__init__.py
@@ -0,0 +1,35 @@
+from __future__ import unicode_literals
+
+
+import arrow
+
+from hubs.widgets.base import Widget
+from hubs.widgets import validators
+
+
+class Halp(Widget):
+
+    name = "halp"
+    label = "Help Requests"
+    position = "both"
+    parameters = [
+        dict(
+            name="hubs",
+            label="Hubs",
+            default=[],
+            validator=validators.CommaSeparatedList,
+            help="A comma-separated list of hubs to monitor.",
+        ), dict(
+            name="per_page",
+            label="Requests per page",
+            default=4,
+            validator=validators.Integer,
+            help="The number of requests per page to display.",
+        )]
+    views_module = ".views"
+    cached_functions_module = ".functions"
+
+    def get_template_environment(self):
+        env = super(Halp, self).get_template_environment()
+        env.filters['humanize'] = lambda d: arrow.get(d).humanize()
+        return env
diff --git a/hubs/widgets/halp/functions.py b/hubs/widgets/halp/functions.py
new file mode 100644
index 0000000..b7f2b88
--- /dev/null
+++ b/hubs/widgets/halp/functions.py
@@ -0,0 +1,149 @@
+from __future__ import unicode_literals
+
+
+import datetime
+import flask
+import fedmsg.meta
+import re
+import logging
+import requests
+import time
+
+from jinja2.utils import urlize
+
+from hubs.widgets.caching import CachedFunction
+
+
+log = logging.getLogger('hubs.widgets')
+
+
+class GetRequests(CachedFunction):
+
+    TOPIC = "org.fedoraproject.prod.meetbot.meeting.item.help"
+
+    def execute(self):
+        # return [_format_message(msg) for msg in EXAMPLE_DATA]
+        from hubs.app import app
+        url = "%s/raw" % app.config["DATAGREPPER_URI"]
+        args = {
+            "topic": self.TOPIC,
+            "delta": str(86400 * 10),  # 10 days
+            # "rows_per_page": per_page,
+        }
+        log.info("Getting request data from Datagrepper")
+        response = requests.get(url, args)
+        if response.status_code != 200:
+            log.warning("Could not get requests from Datagrepper: %s",
+                        response.text)
+            return []
+        raw_messages = response.json()["raw_messages"]
+        return [_format_message(msg) for msg in raw_messages]
+
+    def should_invalidate(self, message):
+        if message["topic"] != self.TOPIC:
+            return False
+        hubs = channel_to_hubs(message['msg']['channel'])
+        return bool(set(hubs) & set(self.instance.config["hubs"]))
+
+
+def channel_to_hubs(channel):
+    # TODO: Link the channel to the hubs that use it.
+    return [channel.lstrip("#")]
+
+
+def _format_message(raw_message):
+    msg = {
+        "date": raw_message["msg"]["details"]["time_"],
+        "channel": raw_message["msg"]["channel"],
+        "hubs": channel_to_hubs(raw_message["msg"]["channel"]),
+        "meeting_topic": raw_message["msg"]["meeting_topic"],
+        "text": raw_message["msg"]["details"]["line"],
+        "context_url": "%s.log.html#l-%s" % (
+            raw_message["msg"]["url"],
+            raw_message["msg"]["details"]["linenum"]),
+        "author": {
+            "name": raw_message["msg"]["details"]["nick"],
+            "avatar": fedmsg.meta.msg2secondary_icon(raw_message["msg"]),
+            "url": flask.url_for(
+                "hub", name=raw_message["msg"]["details"]["nick"]),
+        }
+    }
+    # Extract an URL in the line. Use jinja2's urlize function because the
+    # regexp are better and well-tested.
+    urlized = urlize(raw_message["msg"]["details"]["line"])
+    msg["urls"] = re.findall('<a href="([^"]+)"[^>]*>', urlized)
+    return msg
+
+
+EXAMPLE_DATA = [
+    {
+        "msg": {
+            "details": {
+                "time_": time.mktime((
+                    datetime.datetime.now() - datetime.timedelta(seconds=23)
+                    ).timetuple()),
+                "line": ("The #fedora-apps team needs people to help review "
+                         "code: https://pagure.io/fedora-hubs/issue/21."),
+                "linenum": 42,
+                "nick": "threebean",
+            },
+            "channel": "#fedora-devel",
+            "meeting_topic": "Hubs meeting",
+            "topic": "Hubs meeting",
+            "url": "https://meetbot.fedoraproject.org/fedora-devel/",
+        },
+    },
+    {
+        "msg": {
+            "details": {
+                "time_": time.mktime((
+                    datetime.datetime.now() - datetime.timedelta(hours=2)
+                    ).timetuple()),
+                "line": ("I need help creating signs for Flock: "
+                         "<https://fedorahosted.org/design-team/issue/423> and"
+                         " <https://fedorahosted.org/design-team/issue/424>."),
+                "linenum": 84,
+                "nick": "mizmo",
+            },
+            "channel": "#fedora-design",
+            "meeting_topic": "FLOCK meeting",
+            "topic": "FLOCK meeting",
+            "url": "https://meetbot.fedoraproject.org/fedora-design/",
+        }
+    },
+    {
+        "msg": {
+            "details": {
+                "time_": time.mktime((
+                    datetime.datetime.now() -
+                    datetime.timedelta(days=1, hours=2)
+                    ).timetuple()),
+                "line": ("I need help with the log out bug: "
+                         "https://pagure.io/fedora-hubs/issue/99"),
+                "linenum": 63,
+                "nick": "decause",
+            },
+            "channel": "#fedora-hubs",
+            "meeting_topic": "Hubs meeting",
+            "topic": "Hubs meeting",
+            "url": "https://meetbot.fedoraproject.org/fedora-hubs",
+        }
+    },
+    {
+        "msg": {
+            "details": {
+                "time_": time.mktime((
+                    datetime.datetime.now() -
+                    datetime.timedelta(days=3, hours=20)
+                    ).timetuple()),
+                "line": "Fedora-Hubs should be translated",
+                "linenum": 18,
+                "nick": "mizmo",
+            },
+            "channel": "#fedora-i18n",
+            "meeting_topic": "I18n meeting",
+            "topic": "I18n meeting",
+            "url": "https://meetbot.fedoraproject.org/fedora-hubs",
+        }
+    },
+]
diff --git a/hubs/widgets/halp/templates/halp.html b/hubs/widgets/halp/templates/halp.html
new file mode 100644
index 0000000..908d916
--- /dev/null
+++ b/hubs/widgets/halp/templates/halp.html
@@ -0,0 +1,22 @@
+{% 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, {
+        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
new file mode 100644
index 0000000..1aaf1ad
--- /dev/null
+++ b/hubs/widgets/halp/views.py
@@ -0,0 +1,160 @@
+from __future__ import unicode_literals, absolute_import
+
+import datetime
+import flask
+import time
+
+from hubs.widgets.base import WidgetView
+from .functions import GetRequests
+
+
+def paginate(values, per_page):
+    try:
+        total = values.count()
+    except TypeError:
+        total = len(values)
+    try:
+        page = flask.request.values.get("page", 1, int)
+    except ValueError:
+        page = 1
+    last_page = int(total / per_page)
+    if total > last_page * per_page:
+        last_page += 1
+    if last_page < 1:
+        last_page = 1
+    if page < 1:
+        page = 1
+    if page > last_page:
+        page = last_page
+    start = (page - 1) * per_page
+    end = start + per_page
+    page_data = {
+        "nr": page,
+        "has_prev": page > 1,
+        "has_next": page < last_page,
+        "total_entries": total,
+        "total_pages": last_page,
+    }
+    return values[start:end], page_data
+
+
+class BaseView(WidgetView):
+
+    name = "root"
+    url_rules = ["/"]
+    template_name = "halp.html"
+
+    def get_context(self, instance, *args, **kwargs):
+        return dict(
+            hubs=instance.config["hubs"],
+            title=self.widget.label,
+            )
+
+
+class DataView(WidgetView):
+
+    name = "data"
+    url_rules = ["data"]
+
+    def dispatch_request(self, *args, **kwargs):
+        """
+        The method that subclasses of :py:class:`flask.views.View` must
+        implement. It binds the other methods together.
+        """
+        instance = self._get_instance(*args, **kwargs)
+        get_requests = GetRequests(instance)
+        data = {"requests": []}
+        hubs_filter = flask.request.args.getlist("hubs")
+        for req in get_requests():
+            req["hub"] = req["hubs"][0]  # Arbitrarily assign the first hub.
+            if "hubs" in flask.request.args:
+                # Find which hubs are targeted by this message.
+                hubs = set(req["hubs"]) & set(hubs_filter)
+                if not hubs:
+                    continue
+                # Arbitrarily assign the first common hub.
+                req["hub"] = list(hubs)[0]
+            data["requests"].append(req)
+            if len(data["requests"]) == 3:
+                # Only 3 requests on the main widget.
+                break
+        return flask.jsonify(data)
+
+
+class SearchView(WidgetView):
+
+    name = "search"
+    url_rules = ["search"]
+    methods = ['GET', 'POST']
+
+    def dispatch_request(self, *args, **kwargs):
+        """
+        The method that subclasses of :py:class:`flask.views.View` must
+        implement. It binds the other methods together.
+        """
+        instance = self._get_instance(*args, **kwargs)
+        get_requests = GetRequests(instance)
+        # prepare filters
+        if "hubs" in flask.request.values:
+            # Find which hubs are targeted by this message.
+            filter_hubs = set(flask.request.values.getlist("hubs"))
+        else:
+            filter_hubs = None
+        filter_people = flask.request.values.get("people")
+        filter_meetingname = flask.request.values.get("meetingname")
+        if flask.request.values.get("startdate"):
+            filter_startdate = datetime.datetime.strptime(
+                flask.request.values.get("startdate"), "%Y-%m-%d")
+            filter_startdate = time.mktime(filter_startdate.timetuple())
+        else:
+            filter_startdate = None
+        if flask.request.values.get("enddate"):
+            filter_enddate = datetime.datetime.strptime(
+                flask.request.values.get("enddate"), "%Y-%m-%d")
+            filter_enddate = time.mktime(filter_enddate.timetuple())
+        else:
+            filter_enddate = None
+
+        # filter the data
+        data = {"requests": []}
+        for req in get_requests():
+            req["hub"] = req["hubs"][0]  # Arbitrarily assign the first hub.
+            if filter_hubs is not None and not set(req["hubs"]) & filter_hubs:
+                continue
+            if filter_people and filter_people not in req["author"]["name"]:
+                continue
+            if (filter_meetingname and
+                    filter_meetingname not in req["meeting_topic"]):
+                continue
+            if filter_startdate is not None and req["date"] < filter_startdate:
+                continue
+            if filter_enddate is not None and req["date"] > filter_enddate:
+                continue
+            data["requests"].append(req)
+        requests, page_data = paginate(
+            data["requests"], instance.config["per_page"])
+        data["requests"] = requests
+        data["page"] = page_data
+        return flask.jsonify(data)
+
+
+class RequestersView(WidgetView):
+
+    name = "requesters"
+    url_rules = ["requesters"]
+    MAX_SUGGESTS = 5
+
+    def dispatch_request(self, *args, **kwargs):
+        """
+        The method that subclasses of :py:class:`flask.views.View` must
+        implement. It binds the other methods together.
+        """
+        instance = self._get_instance(*args, **kwargs)
+        get_requests = GetRequests(instance)
+        results = set([
+            req["author"]["name"] for req in get_requests()
+            if req["author"]["name"].startswith(
+                flask.request.args.get("q"))
+            ])
+        results = sorted(list(results))[:self.MAX_SUGGESTS]
+        return flask.jsonify({"results": results})
diff --git a/hubs/widgets/validators.py b/hubs/widgets/validators.py
index a037a94..f1dcea7 100644
--- a/hubs/widgets/validators.py
+++ b/hubs/widgets/validators.py
@@ -148,3 +148,18 @@ class FedorahostedProject(Validator):
         if response.status_code == 200:
             return value
         raise ValueError('Invalid fedorahosted project')
+
+
+class CommaSeparatedList(Validator):
+
+    @classmethod
+    def from_string(cls, value):
+        return [
+            elem.strip() for elem in value.split(",") if elem.strip()
+            ]
+
+    @classmethod
+    def to_string(cls, value):
+        if not value:
+            return ""
+        return ", ".join(value)

From ed8fa02507a900e7bb802aa7be4565cd2fcc7e3d Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Mar 21 2017 09:44:19 +0000
Subject: [PATCH 2/6] Add the Halp configuration panel


---

diff --git a/hubs/static/client/app/widgets/halp/Config.jsx b/hubs/static/client/app/widgets/halp/Config.jsx
new file mode 100644
index 0000000..9e81477
--- /dev/null
+++ b/hubs/static/client/app/widgets/halp/Config.jsx
@@ -0,0 +1,226 @@
+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
+          name="hub-add"
+          url={props.urls.hubs}
+          spinner={props.urls.spinnerCircle}
+          placeholder={props.intl.formatMessage(messages.placeholder)}
+          />
+    ));
+
+    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-default" 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
new file mode 100644
index 0000000..5b821f5
--- /dev/null
+++ b/hubs/static/client/app/widgets/halp/Halp.js
@@ -0,0 +1,4 @@
+import Widget from './Widget.jsx';
+import Config from './Config.jsx';
+
+module.exports = {Widget, Config};
diff --git a/hubs/static/client/app/widgets/halp/Halp.jsx b/hubs/static/client/app/widgets/halp/Halp.jsx
deleted file mode 100644
index f1db852..0000000
--- a/hubs/static/client/app/widgets/halp/Halp.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 HalpWidget 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 ?
-              <img className="center-block" src={this.props.urls.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>
-          <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/Widget.jsx b/hubs/static/client/app/widgets/halp/Widget.jsx
new file mode 100644
index 0000000..71234a4
--- /dev/null
+++ b/hubs/static/client/app/widgets/halp/Widget.jsx
@@ -0,0 +1,182 @@
+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 ?
+              <img className="center-block" src={this.props.urls.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>
+          <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/webpack.config.js b/hubs/static/client/webpack.config.js
index 4bd3fd9..f197a0e 100644
--- a/hubs/static/client/webpack.config.js
+++ b/hubs/static/client/webpack.config.js
@@ -10,7 +10,7 @@ const config = {
     entry: {
         Hubs: path.join(PATHS.app, 'core', 'Hubs.js'),
         Feed: path.join(PATHS.app, 'widgets', 'feed', 'Feed.jsx'),
-        Halp: path.join(PATHS.app, 'widgets', 'halp', 'Halp.jsx')
+        Halp: path.join(PATHS.app, 'widgets', 'halp', 'Halp.js')
     },
     output: {
         path: PATHS.build,
diff --git a/hubs/static/css/style.css b/hubs/static/css/style.css
index 7516bea..7ff066f 100644
--- a/hubs/static/css/style.css
+++ b/hubs/static/css/style.css
@@ -580,6 +580,39 @@ font-size: 32pt;
     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;
+}
+
 
 
 /** fedora bootstrap overrides **/
diff --git a/hubs/widgets/halp/__init__.py b/hubs/widgets/halp/__init__.py
index 92df5a4..474fc83 100644
--- a/hubs/widgets/halp/__init__.py
+++ b/hubs/widgets/halp/__init__.py
@@ -2,9 +2,11 @@ from __future__ import unicode_literals
 
 
 import arrow
+import flask
 
 from hubs.widgets.base import Widget
 from hubs.widgets import validators
+from .views import add_view, hubs_suggest_view
 
 
 class Halp(Widget):
@@ -33,3 +35,22 @@ class Halp(Widget):
         env = super(Halp, self).get_template_environment()
         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
+        app.add_url_rule(rule, endpoint=endpoint, view_func=hubs_suggest_view)
diff --git a/hubs/widgets/halp/templates/halp.html b/hubs/widgets/halp/templates/halp.html
index 908d916..8709b0e 100644
--- a/hubs/widgets/halp/templates/halp.html
+++ b/hubs/widgets/halp/templates/halp.html
@@ -6,7 +6,7 @@
 <script src="{{url_for('static', filename='js/build/Halp.js')}}"></script>
 <script>
 (function() {
-    const Widget = React.createElement(Halp, {
+    const Widget = React.createElement(Halp.Widget, {
         hubs: {{ hubs|tojson }},
         urls: {
             data: '{{ url_for("halp_data", hub=widget_instance.hub.name, idx=widget_instance.idx) }}',
diff --git a/hubs/widgets/halp/templates/halp_config.html b/hubs/widgets/halp/templates/halp_config.html
new file mode 100644
index 0000000..e64a526
--- /dev/null
+++ b/hubs/widgets/halp/templates/halp_config.html
@@ -0,0 +1,29 @@
+<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="center-block" 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 1aaf1ad..46d530e 100644
--- a/hubs/widgets/halp/views.py
+++ b/hubs/widgets/halp/views.py
@@ -4,6 +4,9 @@ import datetime
 import flask
 import time
 
+from hubs.models import Hub
+from hubs.views.utils import get_hub
+from hubs.widgets import registry
 from hubs.widgets.base import WidgetView
 from .functions import GetRequests
 
@@ -139,6 +142,9 @@ class SearchView(WidgetView):
 
 
 class RequestersView(WidgetView):
+    """
+    Returns suggestions for the "requesters" filter.
+    """
 
     name = "requesters"
     url_rules = ["requesters"]
@@ -158,3 +164,62 @@ class RequestersView(WidgetView):
             ])
         results = sorted(list(results))[:self.MAX_SUGGESTS]
         return flask.jsonify({"results": results})
+
+
+class ConfigView(WidgetView):
+    """
+    The custom configuration panel.
+    """
+
+    name = "config"
+    url_rules = ["config"]
+    template_name = "halp_config.html"
+
+    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.
+    """
+    MAX_SUGGESTS = 5
+    results = Hub.query
+    if flask.request.args.get("q"):
+        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]})
+
+
+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]
+    return template.render(
+        mode="add",
+        url=post_url,
+        initial=initial,
+    )

From 6dc1f63a5693c33c8123b17a09a81ebaaf446e44 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Mar 21 2017 09:44:19 +0000
Subject: [PATCH 3/6] Make widget views emit the template signals


And add a way to inspect rendered templates in unit tests.

---

diff --git a/hubs/tests/__init__.py b/hubs/tests/__init__.py
index a3cc5f7..c430043 100644
--- a/hubs/tests/__init__.py
+++ b/hubs/tests/__init__.py
@@ -6,6 +6,7 @@ from datetime import datetime, timedelta
 import os
 
 import munch
+from flask import template_rendered
 from os.path import dirname
 import unittest
 
@@ -155,3 +156,18 @@ class FakeAuthorization(object):
         self.avatar = 'avatar_src_url'
         self.nickname = username
         self.username = username
+
+
+# http://flask.pocoo.org/docs/0.11/signals/#subscribing-to-signals
+
+@contextmanager
+def captured_templates(app):
+    recorded = []
+
+    def record(sender, template, context, **extra):
+        recorded.append((template, context))
+    template_rendered.connect(record, app)
+    try:
+        yield recorded
+    finally:
+        template_rendered.disconnect(record, app)
diff --git a/hubs/widgets/halp/views.py b/hubs/widgets/halp/views.py
index 46d530e..b4ce683 100644
--- a/hubs/widgets/halp/views.py
+++ b/hubs/widgets/halp/views.py
@@ -3,6 +3,7 @@ from __future__ import unicode_literals, absolute_import
 import datetime
 import flask
 import time
+from flask.signals import template_rendered, before_render_template
 
 from hubs.models import Hub
 from hubs.views.utils import get_hub
@@ -218,8 +219,8 @@ def add_view(hub):
         for param in widget.get_parameters()
         )
     initial["hubs"] = [hub.name]
-    return template.render(
-        mode="add",
-        url=post_url,
-        initial=initial,
-    )
+    context = dict(mode="add", url=post_url, initial=initial)
+    before_render_template.send(widget, template=template, context=context)
+    output = template.render(**context)
+    template_rendered.send(widget, template=template, context=context)
+    return output
diff --git a/hubs/widgets/view.py b/hubs/widgets/view.py
index 19ff77c..943302d 100644
--- a/hubs/widgets/view.py
+++ b/hubs/widgets/view.py
@@ -1,5 +1,6 @@
 from __future__ import unicode_literals, absolute_import
 
+from flask.signals import template_rendered, before_render_template
 from flask.views import View
 
 
@@ -97,4 +98,10 @@ class WidgetView(View):
         instance = self._get_instance(*args, **kwargs)
         context = self.get_context(instance, *args, **kwargs)
         context.update(self.get_extra_context(instance, *args, **kwargs))
-        return self.get_template().render(**context)
+        template = self.get_template()
+        before_render_template.send(
+            self.widget, template=template, context=context)
+        output = template.render(**context)
+        template_rendered.send(
+            self.widget, template=template, context=context)
+        return output

From d28b4a91a0028f4d96069779d9f46d00c3c53121 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Mar 21 2017 09:44:19 +0000
Subject: [PATCH 4/6] Map channels in messages to configured channels


---

diff --git a/hubs/widgets/halp/functions.py b/hubs/widgets/halp/functions.py
index b7f2b88..0c30728 100644
--- a/hubs/widgets/halp/functions.py
+++ b/hubs/widgets/halp/functions.py
@@ -1,4 +1,4 @@
-from __future__ import unicode_literals
+from __future__ import unicode_literals, absolute_import
 
 
 import datetime
@@ -12,6 +12,7 @@ import time
 from jinja2.utils import urlize
 
 from hubs.widgets.caching import CachedFunction
+from .utils import find_hubs_for_msg
 
 
 log = logging.getLogger('hubs.widgets')
@@ -42,20 +43,14 @@ class GetRequests(CachedFunction):
     def should_invalidate(self, message):
         if message["topic"] != self.TOPIC:
             return False
-        hubs = channel_to_hubs(message['msg']['channel'])
-        return bool(set(hubs) & set(self.instance.config["hubs"]))
-
-
-def channel_to_hubs(channel):
-    # TODO: Link the channel to the hubs that use it.
-    return [channel.lstrip("#")]
+        hubs = find_hubs_for_msg(message['msg'])
+        return bool(set(hubs) & set(self.instance.config.get("hubs", [])))
 
 
 def _format_message(raw_message):
     msg = {
         "date": raw_message["msg"]["details"]["time_"],
         "channel": raw_message["msg"]["channel"],
-        "hubs": channel_to_hubs(raw_message["msg"]["channel"]),
         "meeting_topic": raw_message["msg"]["meeting_topic"],
         "text": raw_message["msg"]["details"]["line"],
         "context_url": "%s.log.html#l-%s" % (
diff --git a/hubs/widgets/halp/utils.py b/hubs/widgets/halp/utils.py
new file mode 100644
index 0000000..86a5f00
--- /dev/null
+++ b/hubs/widgets/halp/utils.py
@@ -0,0 +1,17 @@
+from __future__ import unicode_literals
+
+from hubs.models import Hub, HubConfig
+
+
+def find_hubs_for_msg(msg):
+    """
+    Returns the hubs working in the given channel.
+
+    TODO: link meeting channels to the hub using them at that time.
+    """
+    return [
+        h[0] for h in
+        Hub.query.join(HubConfig).filter(
+            HubConfig.chat_channel == msg["channel"]
+        ).values(Hub.name)
+        ]
diff --git a/hubs/widgets/halp/views.py b/hubs/widgets/halp/views.py
index b4ce683..3c992f4 100644
--- a/hubs/widgets/halp/views.py
+++ b/hubs/widgets/halp/views.py
@@ -10,6 +10,7 @@ from hubs.views.utils import get_hub
 from hubs.widgets import registry
 from hubs.widgets.base import WidgetView
 from .functions import GetRequests
+from .utils import find_hubs_for_msg
 
 
 def paginate(values, per_page):
@@ -50,7 +51,7 @@ class BaseView(WidgetView):
 
     def get_context(self, instance, *args, **kwargs):
         return dict(
-            hubs=instance.config["hubs"],
+            hubs=instance.config.get("hubs", []),
             title=self.widget.label,
             )
 
@@ -70,6 +71,9 @@ class DataView(WidgetView):
         data = {"requests": []}
         hubs_filter = flask.request.args.getlist("hubs")
         for req in get_requests():
+            req["hubs"] = find_hubs_for_msg(req)
+            if not req["hubs"]:
+                continue
             req["hub"] = req["hubs"][0]  # Arbitrarily assign the first hub.
             if "hubs" in flask.request.args:
                 # Find which hubs are targeted by this message.
@@ -122,6 +126,9 @@ class SearchView(WidgetView):
         # filter the data
         data = {"requests": []}
         for req in get_requests():
+            req["hubs"] = find_hubs_for_msg(req)
+            if not req["hubs"]:
+                continue
             req["hub"] = req["hubs"][0]  # Arbitrarily assign the first hub.
             if filter_hubs is not None and not set(req["hubs"]) & filter_hubs:
                 continue

From 5b80a602143b5263fa48c9a338afe909a62dc53c Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Mar 21 2017 09:44:19 +0000
Subject: [PATCH 5/6] Add tests for the halp widget


---

diff --git a/hubs/tests/test_widgets/test_halp.py b/hubs/tests/test_widgets/test_halp.py
new file mode 100644
index 0000000..8950429
--- /dev/null
+++ b/hubs/tests/test_widgets/test_halp.py
@@ -0,0 +1,107 @@
+from __future__ import unicode_literals, absolute_import
+
+import json
+
+from hubs.models import Hub
+from hubs.tests import captured_templates
+from hubs.tests.test_widgets import WidgetTest
+from hubs.widgets import registry
+
+
+class HalpViewsTestCase(WidgetTest):
+    plugin = 'halp'  # The name in hubs.widgets.registry
+
+    def setUp(self):
+        super(HalpViewsTestCase, self).setUp()
+        self.widget = self.widget_instance('ralph', self.plugin)
+        config = self.widget.config
+        config["hubs"] = ["fedora-devel"]
+
+    def test_root(self):
+        with captured_templates(self.widget.module) as templates:
+            response = self.app.get('/ralph/w/halp/%i/' % self.widget.idx)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(templates), 1)
+        _template, context = templates[0]
+        self.assertEqual(context["hubs"], [])
+
+    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):
+        with captured_templates(self.widget.module) as templates:
+            response = self.app.get('/ralph/w/halp/add?position=right')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(templates), 1)
+        _template, context = templates[0]
+        self.assertEqual(context["mode"], "add")
+        self.assertEqual(context["initial"], {
+            "hubs": ["ralph"], "per_page": 4,
+            })
+        self.assertEqual(context["url"], "/ralph/add/halp?position=right")
+
+    def test_config_edit(self):
+        with captured_templates(self.widget.module) as templates:
+            response = self.app.get('/ralph/w/halp/%i/config'
+                                    % self.widget.idx)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(templates), 1)
+        _template, context = templates[0]
+        self.assertEqual(context["mode"], "edit")
+        self.assertEqual(context["initial"], {})
+        self.assertEqual(context["url"], "/ralph/%i/edit" % self.widget.idx)
+
+    def test_config_hubs_suggest(self):
+        response = self.app.get('/w/halp/hubs')
+        self.assertEqual(response.status_code, 200)
+        data = json.loads(response.get_data(as_text=True))
+        self.assertDictEqual(data, {
+            'results': ['decause', 'devyani7', 'dhrish', 'i18n', 'infra']
+        })
+
+
+class HalpFunctionsTestCase(WidgetTest):
+    plugin = 'halp'  # The name in hubs.widgets.registry
+
+    def setUp(self):
+        super(HalpFunctionsTestCase, self).setUp()
+        module = registry[self.plugin]
+        func_class = module.get_cached_functions()['GetRequests']
+        self.widget = self.widget_instance('ralph', self.plugin)
+        self.func = func_class(self.widget)
+        # The tested widget watches the infra hub.
+        config = self.widget.config
+        config["hubs"] = ["infra"]
+        self.widget.config = config
+        # The infra hub works in the fedora-infra channel.
+        infra_hub = Hub.query.filter_by(name="infra").one()
+        infra_hub.config.chat_channel = "#fedora-infra"
+        self.session.commit()
+
+    def test_should_invalidate_wrong_topic(self):
+        msg = {'topic': 'hubs.WRONG.TOPIC'}
+        result = self.func.should_invalidate(msg)
+        self.assertFalse(result)
+
+    def test_should_invalidate_wrong_hub(self):
+        # The decause hub works in the fedora-commops channel.
+        decause_hub = Hub.query.filter_by(name="decause").one()
+        decause_hub.config.chat_channel = "#fedora-commops"
+        self.session.commit()
+        msg = {'topic': 'org.fedoraproject.prod.meetbot.meeting.item.help',
+               'msg': {'channel': '#fedora-commops'},
+               }
+        # The decause hub cache would be invalidated, but not ours.
+        result = self.func.should_invalidate(msg)
+        self.assertFalse(result)
+
+    def test_should_invalidate_good_match(self):
+        msg = {'topic': 'org.fedoraproject.prod.meetbot.meeting.item.help',
+               'msg': {'channel': '#fedora-infra'},
+               }
+        result = self.func.should_invalidate(msg)
+        self.assertTrue(result)

From eef96dc161bfe0f05cf315c03bdda1d8cd2f4492 Mon Sep 17 00:00:00 2001
From: Aurélien Bompard <aurelien@bompard.org>
Date: Mar 22 2017 22:58:16 +0000
Subject: [PATCH 6/6] Implement most of the review comments and add tests


---

diff --git a/hubs/tests/__init__.py b/hubs/tests/__init__.py
index c430043..67d82fa 100644
--- a/hubs/tests/__init__.py
+++ b/hubs/tests/__init__.py
@@ -158,10 +158,21 @@ class FakeAuthorization(object):
         self.username = username
 
 
-# http://flask.pocoo.org/docs/0.11/signals/#subscribing-to-signals
-
 @contextmanager
 def captured_templates(app):
+    """Context manager to inspect rendered templates.
+
+    See http://flask.pocoo.org/docs/0.11/signals/#subscribing-to-signals for
+    usage.
+
+    The argument is the signal emitter.  When calling a core Hubs view, this is
+    the Hubs app (``hubs.app.app`` or ``self.app.application`` if you're in an
+    :py:class:`APPTest` subclass).  If you're calling a widget view, the signal
+    emitter is the widget class, as found in the widgets registry.
+
+    Args:
+        app: The signal emitter.
+    """
     recorded = []
 
     def record(sender, template, context, **extra):
diff --git a/hubs/tests/test_widgets/test_halp.py b/hubs/tests/test_widgets/test_halp.py
index 8950429..81c1831 100644
--- a/hubs/tests/test_widgets/test_halp.py
+++ b/hubs/tests/test_widgets/test_halp.py
@@ -8,14 +8,105 @@ from hubs.tests.test_widgets import WidgetTest
 from hubs.widgets import registry
 
 
+SAMPLE_DATA = [
+    {
+        'author': {
+            'url': '/mattdm/', 'name': 'mattdm', 'avatar': None,
+            },
+        'channel': u'#fedora-meeting',
+        'context_url': (
+            'https://meetbot.fedoraproject.org/fedora-meeting/'
+            '2017-03-17/fesco.2017-03-17-16.00.log.html#l-466'
+            ),
+        'date': 1489773443.0,
+        'meeting_topic': 'FESCO (2017-03-17)',
+        'text': ('"How to enable trim on your SSD drive" would '
+                 'be a good Fedora Magazine article'),
+        'urls': [],
+    }, {
+        'author': {
+            'url': '/jkurik/', 'name': 'jkurik', 'avatar': None,
+            },
+        'channel': '#fedora-meeting-2',
+        'context_url': (
+            'https://meetbot.fedoraproject.org/fedora-meeting-2/2017-03-16/'
+            'f26-alpha-readiness-meeting.2017-03-16-19.00.log.html#l-103'),
+        'date': 1489692368.0,
+        'meeting_topic': 'F26 Alpha Readiness Meeting',
+        'text': ('The Marketing team wants to ask for help from '
+                 'Spins, Labs SIGs and DEs SIGs to populate the '
+                 'talking points that help us to marketing people'),
+        'urls': [],
+    }, {
+        'author': {
+            'url': '/stoney/', 'name': 'stoney', 'avatar': None,
+            },
+        'channel': '#foss2serve',
+        'context_url': (
+            'https://meetbot.fedoraproject.org/foss2serve/2017-03-14/'
+            'posse_irc_meeting_1.2.2017-03-14-17.00.log.html#l-187'),
+        'date': 1489512523.0,
+        'meeting_topic': 'POSSE IRC Meeting 1.2',
+        'text': '',
+        'urls': [],
+    }, {
+        'author': {
+            'url': '/nitzmahone/', 'name': 'nitzmahone', 'avatar': None,
+            },
+        'channel': '#ansible-meeting',
+        'context_url': (
+            'https://meetbot.fedoraproject.org/ansible-meeting/2017-03-14/'
+            'windows_working_group.2017-03-14-00.00.log.html#l-130'),
+        'date': 1489451407.0,
+        'meeting_topic': 'Windows Working Group',
+        'text': '',
+        'urls': [],
+    }, {
+        'author': {
+            'url': '/jborean93/', 'name': 'jborean93', 'avatar': None
+            },
+        'channel': '#ansible-meeting',
+        'context_url': (
+            'https://meetbot.fedoraproject.org/ansible-meeting/2017-03-14/'
+            'windows_working_group.2017-03-14-00.00.log.html#l-59'),
+        'date': 1489450330.0,
+        'meeting_topic': 'Windows Working Group',
+        'text': 'with installers win_msi vs win_product',
+        'urls': [],
+    },
+]
+
+
 class HalpViewsTestCase(WidgetTest):
     plugin = 'halp'  # The name in hubs.widgets.registry
+    maxDiff = None
 
     def setUp(self):
         super(HalpViewsTestCase, self).setUp()
         self.widget = self.widget_instance('ralph', self.plugin)
         config = self.widget.config
         config["hubs"] = ["fedora-devel"]
+        config["per_page"] = 3
+        self.widget.config = config
+        infra_hub = Hub.query.get("infra")
+        infra_hub.config.chat_channel = "#fedora-meeting"
+        devyani7 = Hub.query.get("devyani7")
+        devyani7.config.chat_channel = "#fedora-meeting-2"
+        decause = Hub.query.get("decause")
+        decause.config.chat_channel = "#ansible-meeting"
+        dhrish = Hub.query.get("dhrish")
+        dhrish.config.chat_channel = "#foss2serve"
+        self.session.commit()
+
+    def _get_sample_data_with_hub_keys(self):
+        hub_names = ["infra", "devyani7", "dhrish", "decause", "decause"]
+        data = [req.copy() for req in SAMPLE_DATA]
+        for index, hub_name in enumerate(hub_names):
+            data[index].update({
+                'hub': hub_name,
+                'hubs': [hub_name],
+                })
+        return data
 
     def test_root(self):
         with captured_templates(self.widget.module) as templates:
@@ -23,7 +114,7 @@ class HalpViewsTestCase(WidgetTest):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(len(templates), 1)
         _template, context = templates[0]
-        self.assertEqual(context["hubs"], [])
+        self.assertEqual(context["hubs"], ['fedora-devel'])
 
     def test_json(self):
         response = self.app.get('/ralph/%i/json' % self.widget.idx)
@@ -52,7 +143,10 @@ class HalpViewsTestCase(WidgetTest):
         self.assertEqual(len(templates), 1)
         _template, context = templates[0]
         self.assertEqual(context["mode"], "edit")
-        self.assertEqual(context["initial"], {})
+        self.assertEqual(context["initial"], {
+            'hubs': ['fedora-devel'],
+            'per_page': 3,
+            })
         self.assertEqual(context["url"], "/ralph/%i/edit" % self.widget.idx)
 
     def test_config_hubs_suggest(self):
@@ -62,6 +156,152 @@ class HalpViewsTestCase(WidgetTest):
         self.assertDictEqual(data, {
             'results': ['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']
+        })
+
+    def test_data(self):
+        expected = self._get_sample_data_with_hub_keys()
+        # Test with no hub selected
+        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]})
+        # 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]})
+
+    def test_data_wrong_hub(self):
+        # Test with the wrong hub selected
+        response = self.app.get('/ralph/w/halp/%i/data?hubs=ralph'
+                                % self.widget.idx)
+        self.assertEqual(response.status_code, 200)
+        data = json.loads(response.get_data(as_text=True))
+        self.assertEqual(data, {"requests": []})
+
+    def test_search_requesters(self):
+        response = self.app.get(
+            '/ralph/w/halp/%i/requesters' % self.widget.idx)
+        self.assertEqual(response.status_code, 200)
+        data = json.loads(response.get_data(as_text=True))
+        self.assertDictEqual(data, {
+            'results': ['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']
+        })
+
+    def test_search_all(self):
+        expected = self._get_sample_data_with_hub_keys()
+        response = self.app.get(
+            '/ralph/w/halp/%i/search' % self.widget.idx)
+        self.assertEqual(response.status_code, 200)
+        data = json.loads(response.get_data(as_text=True))
+        self.assertEqual(data, {
+            "requests": expected[:3],
+            'page': {
+                'has_next': True,
+                'has_prev': False,
+                'nr': 1,
+                'total_entries': 5,
+                'total_pages': 2,
+                },
+            })
+        # Page 2
+        response = self.app.get(
+            '/ralph/w/halp/%i/search?page=2' % self.widget.idx)
+        self.assertEqual(response.status_code, 200)
+        data = json.loads(response.get_data(as_text=True))
+        self.assertEqual(data, {
+            "requests": expected[3:],
+            'page': {
+                'has_next': False,
+                'has_prev': True,
+                'nr': 2,
+                'total_entries': 5,
+                'total_pages': 2,
+                },
+            })
+
+    def test_search_hub(self):
+        response = self.app.get(
+            '/ralph/w/halp/%i/search?hubs=infra' % self.widget.idx)
+        self.assertEqual(response.status_code, 200)
+        data = json.loads(response.get_data(as_text=True))
+        expected = self._get_sample_data_with_hub_keys()
+        self.assertEqual(data, {
+            "requests": expected[0:1],
+            'page': {
+                'has_next': False,
+                'has_prev': False,
+                'nr': 1,
+                'total_entries': 1,
+                'total_pages': 1
+                },
+            })
+
+    def test_search_people(self):
+        response = self.app.get(
+            '/ralph/w/halp/%i/search?people=nitzmahone' % self.widget.idx)
+        self.assertEqual(response.status_code, 200)
+        data = json.loads(response.get_data(as_text=True))
+        expected = self._get_sample_data_with_hub_keys()
+        self.assertEqual(data, {
+            "requests": expected[3:4],
+            'page': {
+                'has_next': False,
+                'has_prev': False,
+                'nr': 1,
+                'total_entries': 1,
+                'total_pages': 1
+                },
+            })
+
+    def test_search_meetingname(self):
+        response = self.app.get(
+            '/ralph/w/halp/%i/search?meetingname=Readiness' % self.widget.idx)
+        self.assertEqual(response.status_code, 200)
+        data = json.loads(response.get_data(as_text=True))
+        expected = self._get_sample_data_with_hub_keys()
+        self.assertEqual(data, {
+            "requests": expected[1:2],
+            'page': {
+                'has_next': False,
+                'has_prev': False,
+                'nr': 1,
+                'total_entries': 1,
+                'total_pages': 1
+                },
+            })
+
+    def test_search_date(self):
+        response = self.app.get(
+            '/ralph/w/halp/%i/search?startdate=2017-03-16&enddate=2017-03-17'
+            % self.widget.idx)
+        self.assertEqual(response.status_code, 200)
+        data = json.loads(response.get_data(as_text=True))
+        expected = self._get_sample_data_with_hub_keys()
+        self.assertEqual(data, {
+            "requests": expected[1:2],
+            'page': {
+                'has_next': False,
+                'has_prev': False,
+                'nr': 1,
+                'total_entries': 1,
+                'total_pages': 1
+                },
+            })
 
 
 class HalpFunctionsTestCase(WidgetTest):
@@ -82,6 +322,11 @@ class HalpFunctionsTestCase(WidgetTest):
         infra_hub.config.chat_channel = "#fedora-infra"
         self.session.commit()
 
+    def test_execute(self):
+        with self.app.application.test_request_context('/'):
+            result = self.func.execute()
+        self.assertEqual(result, SAMPLE_DATA)
+
     def test_should_invalidate_wrong_topic(self):
         msg = {'topic': 'hubs.WRONG.TOPIC'}
         result = self.func.should_invalidate(msg)
@@ -105,3 +350,98 @@ class HalpFunctionsTestCase(WidgetTest):
                }
         result = self.func.should_invalidate(msg)
         self.assertTrue(result)
+
+
+class PaginateTestCase(WidgetTest):
+
+    def setUp(self):
+        super(PaginateTestCase, self).setUp()
+        from hubs.widgets.halp.utils import paginate  # circular
+        self.paginate = paginate
+        self.values = ["A", "B", "C", "D"]
+
+    def test_base(self):
+        with self.app.application.test_request_context('/'):
+            result = self.paginate(self.values, 3)
+        self.assertListEqual(result[0], self.values[:3])
+        self.assertDictEqual(result[1], {
+            'has_next': True,
+            'has_prev': False,
+            'nr': 1,
+            'total_entries': len(self.values),
+            'total_pages': 2,
+            })
+
+    def test_page_2(self):
+        with self.app.application.test_request_context('/?page=2'):
+            result = self.paginate(self.values, 3)
+        self.assertListEqual(result[0], self.values[3:])
+        self.assertDictEqual(result[1], {
+            'has_next': False,
+            'has_prev': True,
+            'nr': 2,
+            'total_entries': len(self.values),
+            'total_pages': 2,
+            })
+
+    def test_page_0(self):
+        with self.app.application.test_request_context('/?page=0'):
+            result = self.paginate(self.values, 3)
+        self.assertListEqual(result[0], self.values[:3])
+        self.assertDictEqual(result[1], {
+            'has_next': True,
+            'has_prev': False,
+            'nr': 1,
+            'total_entries': len(self.values),
+            'total_pages': 2,
+            })
+
+    def test_invalid_page(self):
+        with self.app.application.test_request_context('/?page=blah'):
+            result = self.paginate(self.values, 3)
+        self.assertListEqual(result[0], self.values[:3])
+        self.assertDictEqual(result[1], {
+            'has_next': True,
+            'has_prev': False,
+            'nr': 1,
+            'total_entries': len(self.values),
+            'total_pages': 2,
+            })
+
+    def test_page_too_high(self):
+        with self.app.application.test_request_context('/?page=3'):
+            result = self.paginate(self.values, 3)
+        self.assertListEqual(result[0], self.values[3:])
+        self.assertDictEqual(result[1], {
+            'has_next': False,
+            'has_prev': True,
+            'nr': 2,
+            'total_entries': len(self.values),
+            'total_pages': 2,
+            })
+
+    def test_single_page(self):
+        values = self.values[:3]
+        with self.app.application.test_request_context('/'):
+            result = self.paginate(values, 3)
+        self.assertListEqual(result[0], values)
+        self.assertDictEqual(result[1], {
+            'has_next': False,
+            'has_prev': False,
+            'nr': 1,
+            'total_entries': len(values),
+            'total_pages': 1,
+            })
+
+    def test_no_value(self):
+        values = []
+        with self.app.application.test_request_context('/'):
+            result = self.paginate(values, 3)
+        self.assertListEqual(result[0], [])
+        self.assertDictEqual(result[1], {
+            'has_next': False,
+            'has_prev': False,
+            'nr': 1,
+            'total_entries': 0,
+            'total_pages': 1,
+            })
diff --git a/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpFunctionsTestCase.test_execute b/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpFunctionsTestCase.test_execute
new file mode 100644
index 0000000..1e03fdc
--- /dev/null
+++ b/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpFunctionsTestCase.test_execute
@@ -0,0 +1,98 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.13.0]
+    method: GET
+    uri: https://apps.fedoraproject.org/datagrepper/raw?topic=org.fedoraproject.prod.meetbot.meeting.item.help&delta=864000
+  response:
+    body:
+      string: !!binary |
+        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
+    headers:
+      appserver: [proxy04.fedoraproject.org]
+      apptime: [D=630853]
+      connection: [Keep-Alive]
+      content-encoding: [gzip]
+      content-length: ['4029']
+      content-type: [application/json]
+      date: ['Wed, 22 Mar 2017 18:26:01 GMT']
+      keep-alive: ['timeout=15, max=500']
+      server: [Apache/2.4.6 (Red Hat Enterprise Linux) mod_wsgi/3.4 Python/2.7.5]
+      strict-transport-security: [max-age=15768000; includeSubDomains; preload]
+      vary: [Accept-Encoding]
+    status: {code: 200, message: OK}
+version: 1
diff --git a/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_data b/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_data
new file mode 100644
index 0000000..4d48985
--- /dev/null
+++ b/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_data
@@ -0,0 +1,194 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.13.0]
+    method: GET
+    uri: https://apps.fedoraproject.org/datagrepper/raw?topic=org.fedoraproject.prod.meetbot.meeting.item.help&delta=864000
+  response:
+    body:
+      string: !!binary |
+        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
+    headers:
+      appserver: [proxy06.fedoraproject.org]
+      apptime: [D=634315]
+      connection: [Keep-Alive]
+      content-encoding: [gzip]
+      content-length: ['4028']
+      content-type: [application/json]
+      date: ['Wed, 22 Mar 2017 18:58:53 GMT']
+      keep-alive: ['timeout=15, max=500']
+      server: [Apache/2.4.6 (Red Hat Enterprise Linux) mod_wsgi/3.4 Python/2.7.5]
+      strict-transport-security: [max-age=15768000; includeSubDomains; preload]
+      vary: [Accept-Encoding]
+    status: {code: 200, message: OK}
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.13.0]
+    method: GET
+    uri: https://apps.fedoraproject.org/datagrepper/raw?topic=org.fedoraproject.prod.meetbot.meeting.item.help&delta=864000
+  response:
+    body:
+      string: !!binary |
+        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
+    headers:
+      appserver: [proxy04.fedoraproject.org]
+      apptime: [D=583692]
+      connection: [Keep-Alive]
+      content-encoding: [gzip]
+      content-length: ['4027']
+      content-type: [application/json]
+      date: ['Wed, 22 Mar 2017 19:24:03 GMT']
+      keep-alive: ['timeout=15, max=500']
+      server: [Apache/2.4.6 (Red Hat Enterprise Linux) mod_wsgi/3.4 Python/2.7.5]
+      strict-transport-security: [max-age=15768000; includeSubDomains; preload]
+      vary: [Accept-Encoding]
+    status: {code: 200, message: OK}
+version: 1
diff --git a/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_data_wrong_hub b/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_data_wrong_hub
new file mode 100644
index 0000000..6b0a36a
--- /dev/null
+++ b/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_data_wrong_hub
@@ -0,0 +1,98 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.13.0]
+    method: GET
+    uri: https://apps.fedoraproject.org/datagrepper/raw?topic=org.fedoraproject.prod.meetbot.meeting.item.help&delta=864000
+  response:
+    body:
+      string: !!binary |
+        H4sIAAAAAAAAA+2baY/qSpKG/wpivnTrHAqvgI/UH8pgAwZMeUuDp1ql9AI2XvFSLK3z3ydsoJbT
+        de/oztyRrkZIJZUz7YyMCEcmz4vNv9o431axl5RF+8e/2g4uvW2aBx60/vOf39tOmpQ4SK4t14tK
+        3P4x6DEEQTwQ39te4rZ/kAxHUCTBUVTdtc3TKvOge4Ojwvvejr16SD06ScuXX+3XfRl2Qrz92FOm
+        WeB8aFeFl1+bae56efsHuFI47e/tz2MzOAR/vrfz9FC8ZF7+cumh2O/tosR5WTs74Gimd3X2bSKw
+        u33YeG6a4yxPd55TPsB/9yH2vNJOy+Z/kGwfgtKLH3wvytow3ZtbP+tEVQmYZy9OFBcv8OEl9ori
+        6iBk18vLYBPUSYAQ5hpRzjVyqBqupAf8SEWSoRmWpBCioTbniHIYkpJmIIRDeaToLmkRIq0ZEm8R
+        0UhBKeMSqmoa2fw5sclMwhNrp4eiqhrW0DBE2qJE3hPknmJkQ4tgTSUULSSIBYqUmY7Es0vIMBr+
+        kCipK3emk2JPRaL8nCi6OlF0EamGxOiGj2zDOpuRW9ixKKjIl5UIXc/LqW5wmo2sfL2TZ/gxk9RQ
+        Ytc76WCL4dlY+cJz4oXiUondJQol3iHVoY2ywgydI+QR5uNlReev1kSYzcWNtchf2iGpLieuYKBI
+        GEaWaqx48TlxyawEXzR3vDitaX+GQzEH67lj+jk2HNY980OFFEVErE9qXAaIOpJGbVXMJstVZJnC
+        u3/PibEyZrXnMNdQFn0BCcZBN1TLJThDW0WyOnYOOrJeTUE1NJ2XVTGi9TiTPIMUrDMvo5iDPKMm
+        z5C3j5l+zHhbtIzfy/Tv5fk5+a1MD+PpWYv9f8v07+UZrK0sWTl/yLRJ7hakRVvInbmxOlro05mJ
+        XGaBMkNZ+Qs7lF4tQ5VclJUI8rggsnRN+HCtWD4nKM5C5YyoNUXuPOHYU1bqNXI5R0JW6Ttxp42P
+        GeRWdR+zsI7MEl3kEqQur1xfG7usPiabu/Kc/HpfjBWq56t0JMOdyPi6xj9WOCakMR6r6TDKJosV
+        H0D7UhOxDL6pibuz9egIPcuFcCzBOvZCeWgnGa+HUoJpMcVUBNXgHJTLeuEVgxviSORdyrUwsZhZ
+        ZCRCFornxDFZzab5CHK0sIac4oXSVCPV+RLJIqzFPTaPvkOISyeMxgtCHuJYoty4FJcrnnGQX4FP
+        2IjJuWJET8/J4jFbuDofaCM3mJOugWM/tATlNCeQqBrl0RojCYXRTItFAwtH1UbqREt42hxHiiUs
+        jkvdesKTjFZF/wirXswQNqzQGMn7Yehm6khkLbQ+K7T8ikeqKYe+ZCYRgwiyZxFqukBovNAtf0YJ
+        J0UXSG0sV7Ozy6/pCFYW7Dw0VAa/NjjYXawd5CJR673CEHOVEI7IVEWXFEfQ5l3iWO88loYySUFR
+        IItiohrsEGystHFpPid4vDhjWkW2wA5hDwvtsVSiBO6zKEFVykP1McOmkB7WyMWOGJ7UHY9hdwov
+        FeyrsoDGFikiGz2SYG1YWrZwlGQxk2ZnX1iYJKONRdMUrCNET4OvITJcHiIMh2FEG4TUQ4I89EyO
+        R6ZwUGIhN8JIwsiqIG+RGyFjzRpGFCpUQc4JqADKT9ammMOuKYM1H+7+CFbU7LaiTKTMFPNoIoFU
+        Hdod1bsSikT1OYHoaC3kVC9Eqm4IRJMrAw09gZwikxw7ZLayx6yvCmitGxK6nh9hgXyCyjDxIWtW
+        F+z+Q9gtzenJGR+tBSky6uRSsXpkDZWVbCjxZcUsVhlUNFJgtsPVGuxA5MSsrVHSWo9Z2dB9+jkZ
+        hupt5znAzgN7a7YyEnW3iGVGizMeVs4v60FWVQR7a1jvwW7hIhGsHrF6jtjnxET+8OLfYWbqsLLN
+        eidiE4sqeXkk8haaHpaiP8UkomE2SxUtbTE+gu8urcDK0oySNg1pCB9pU6i3lSvBzkg3/u14pVmL
+        sGvW++klMll3DFfXYBZVFEUllFQEtScL7lAxQtZKBNirJQmb0RJ2JMHP9Vh9XQiZoK+sVx1FPUUr
+        BWsEEU54HqpiNCP9eH2WoQ7FXD+7K1cozkoYMZC3ub7zU7yTl1ao5uBbJO1gNY2U8yOzDhekRlhL
+        lXC1dazqiIxKXVen65WoYNGZaTSf4Z00dCJ5BauYd3YikpEY2+fI8gTRgE8ZMiRsQrWM1fa0GAsn
+        Y2ScNOEIlcGfbTEazmm0Ns5ypY5FCSdRBVWto222dmj+STNL5D2mDQ/MDQSRqsPfoIXtP/7xnAAW
+        Be0f/UEf4KvY1lSHyxJIzfMaxFNx6eMkASoCRNHSqvS9PHkZAwFGXtxwi5O6aeJFEVzCAPRtgyhO
+        cyCWPpxzI8+PcVKD1Pf25uAFcY1jgD27TZrb9QzUABpFHJQ+XASMtTvY0AkHIY68Vzj83o6CpDrG
+        qdugHLRjcNCFuXtMfXzEcRBFQQqTwKg4TKNmQhpYMMiDEGarcW6Lowhv6zlouOqcuoBp0OjVLObj
+        IP8ca5lX3sdQLu03n6/Nq9fXVu335fDq+aXxycFL19WvS+Pds7p9cSeBdALx/ceFMDtXmmzXRA14
+        HTW+QlJqKnxuT9JDq0xbXoLtyGuVeRC30qR1Squ8pWmjlpsHr95zu3VIq8ht2V4Lt7Zp6rbExnZr
+        gbf4DKZagLuBE3ntJt1eUkF+mV6vdtYBX285BwaGW/hyAeN+n2YY+oH4WWN74+IFxeFyUdCGy9bf
+        KILsdwi6Q/b/DkPTQ9LA+FvIV6KuQyV7g0FrmjhpnEVe6bX+lkAQpQcYDlH9vTWEpAAWt9JNS6R6
+        MLDK6wz5ZZkVP7rdG3d/RnKA9O7nFHbfHYIzhZM+vHd0yN4DQbR/NsvgJQBJ0m5Obpie16M4tuNy
+        HtVhOJbrDFyb69AOtYEUYI7CtUNFsE1wWdXV0nbJqSiU80i2vhFGJg0LY9rn16PTqPK00vJTe3+M
+        uWBjE8dNOqhmQa9fPU3ZEaH3i9WImQ3WZ7py6BmlrkPY9gaeu12WjwNVWhHigYuN3cafPKrVyTCl
+        vMtuOfjMylQ3Tjc9bK6S7tlbzPUpOz1PQna5BybF2pOh5ZI8FlbPiW/Mq+zxqGh+zhwcZaRbxmUT
+        KKBiHO8lwXETAi5xksb1cn079Qr6pSnjNvHQe2CvxQC3KM4+F8T7ff3DOunn97vuueueu+656567
+        7rnrnrvuueue/2PdQw++1j3YxfGhkRR2STRyAv67aZkEyQ43/e/yAE7uwuoiNpirFCBr9A+iLW5O
+        3yRLrVBsUDLV5TA4xyn48CYJSKr+QjgHJr+omSzHr83X6UT99XDipnH9JfKlmdont6rZvGkUftDM
+        +S4nevWX2ADvRXkdcaRh6lNz4qZ/+p/kzzXkiy75FO1v6KFrzDehc4n21roEfG1dY762LmF/IYbe
+        Yr82b+Ffmh8zcO15S8Kt3eThK2X1KRuXjltC/jvh1aG+lF6674F8ysPmGtAqOG4dcFIWtRrDRdgC
+        tdiqmba1ydO4pWUQyffWHNtFS5uOixYE0xoJ1wYMydKsigB5W6CwWyWOwtpqlgaNRR+XF1tVc238
+        Nm3mpdln1UYS9Jtqu96hT6qtx1F0b/C1aqN6rcco83FL9bALBouitXgTnzf59m70OuwtCf9jXdah
+        3pVZr7uheh1cu9HJb27cLnzXa70OyX2p1xzKZgncJzo07W46DO0QHZthyQ7HuX2CGtADl+F+0Wuv
+        efdks/SgJ8yGS3S2reA4LudP/qEM9QW7P4drS5W0QllNe3O1L2ZdtTqTipa5Twtff0x2r8tv6dpw
+        t/G8qz8ngk8xu9zwJkpP257kQAzHvVz1q6O2oT1aY8r8cTthzurqyXnc5K/f+OVrRD9KDNcN1o/C
+        xH6Vtk8oFJ62Xk95Tqb7nDBXlSUU8tTM5/s99fin6bVbKdz12l2v3fXaXa/d9dpdr9312l2v/YX1
+        Gt1jv9ZrPD42aA/qZhjhGutASy216QIXRc1AILIA2G9XASI7x31zje8FblC/BwRX+OkB5+6mOX89
+        fmkaYVUUMa6iRmMBapf7yvOSl22QgOJhLnLn5eWlmX5f4ZpGQVfmfnoCOK+fd+VZEF5eNirKNPFO
+        V06/NF6aS0oM8iRNG6fK4iLnTmHY2L8JNuaTYLu5fn3s9ObjTe9cJvpC3qRFQYGMevW+FDaf1MSg
+        /6YmrgY/qQmWpFjq62dAT0tNE1pTdXjTEC3ygfr4GOjN3HXAlTL/iIp4C+RdPzDdDHq9lyB3Xm4u
+        wcTvyoHpkP0vlQO5cUii3+t3+v1Bv8OwHgbRAELCcTDlOJ4NexT965Mez+Bfd/v1zkyLdEBs9c1k
+        DEVZGGtHLMbhiSeyZKL76miNXpP0IK/irjAOJuGOla1zovGwlviYkre26s5rXFCnG87IfGnpVQeZ
+        XK8RQwxDrdR7tqZzxJQ0PCG3xVjEzlyDjyFmqwrBYJMUrLJwHj0/33kTtr+y1T4sp3gG5/B4QysE
+        68Zox9h/3pOe222/K4e7crgrh7tyuCuHu3K4K4e7cvgLKwfqt95wczH00fU7aXaaezjh6Abedz4+
+        hF5xSPPm1bT6GU9ZE3R+aSRBeY6xn9bEXL9hdiP0z2+UNbavT0verV87Pk1w7btNcXs48j7Jv2M8
+        TorAjrzffT/sE8vXr75dWf6D5U88z7AkQ/S/5HkzSNz0ULTMNG+eTYzrn2584PnPJq+DqAe6BdxY
+        VsUfwPpfAvvI9oeLEy+HixMvze9HPrJ9/UuTL9iewh5JMQTRYbALbI97dGfgYbrD2TRHsn2K4Ajv
+        F7ZX065ULgNrHsHH5n4l8fbqvD7P8sTc2wRZjjhOPK0yfz/vcYti5us0uafOZbSgSV+KQ3vulHa+
+        sipDlWUAyLgczXBCDdfIOs3Zg+bJmp0y38jhK2G6yyNxDPmjdPIj1nvs+t8OjrxPy9H5tUy4gXFS
+        Dry93gSMpMyx7gO6uKVthwsVPqLtp0fb6p485U9j+1sJ3Nn+zvZ3tr+z/Z3t72x/Z/s72/+l2Z78
+        HbYffILv/q/kzX7AbvYzc9c/TvmLgv0hKP1WkAC3RREQXQvA+CUugtbr5bAGtMopP+I/y72/F/Tm
+        8i/wT9A08f8P/u2+6w08j+p4NEN2GNoedDh2QHXcAcl5G4pkKdL9Bf635p6KKmWJ+PVsvBOe0NFb
+        cPNe/4x23a5MDp7K+X7AreKz8SpNplwgn4+yES+ndjEaPpW5KGVTUupuGIMrFs9JsRKfJmi1kb8F
+        BgDRVBOy0Xrdm/vyaD/TRAtLVs9h3O3hZD51R6fM6MlMl04kMpK7+yNddrs7N3EG+1NwhPW1GKQV
+        ZwexVI2z0KDMAV/8ifB/KYH/Dfz/sx4MdQkl9/O/ADfmpbMxQAAA
+    headers:
+      appserver: [proxy02.fedoraproject.org]
+      apptime: [D=886956]
+      connection: [Keep-Alive]
+      content-encoding: [gzip]
+      content-length: ['4029']
+      content-type: [application/json]
+      date: ['Wed, 22 Mar 2017 19:28:42 GMT']
+      keep-alive: ['timeout=15, max=500']
+      server: [Apache/2.4.6 (Red Hat Enterprise Linux) mod_wsgi/3.4 Python/2.7.5]
+      strict-transport-security: [max-age=15768000; includeSubDomains; preload]
+      vary: [Accept-Encoding]
+    status: {code: 200, message: OK}
+version: 1
diff --git a/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_search_all b/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_search_all
new file mode 100644
index 0000000..dfd7580
--- /dev/null
+++ b/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_search_all
@@ -0,0 +1,194 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.13.0]
+    method: GET
+    uri: https://apps.fedoraproject.org/datagrepper/raw?topic=org.fedoraproject.prod.meetbot.meeting.item.help&delta=864000
+  response:
+    body:
+      string: !!binary |
+        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
+    headers:
+      appserver: [proxy11.fedoraproject.org]
+      apptime: [D=574648]
+      connection: [Keep-Alive]
+      content-encoding: [gzip]
+      content-length: ['4029']
+      content-type: [application/json]
+      date: ['Wed, 22 Mar 2017 19:53:38 GMT']
+      keep-alive: ['timeout=15, max=500']
+      server: [Apache/2.4.6 (Red Hat Enterprise Linux) mod_wsgi/3.4 Python/2.7.5]
+      strict-transport-security: [max-age=15768000; includeSubDomains; preload]
+      vary: [Accept-Encoding]
+    status: {code: 200, message: OK}
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.13.0]
+    method: GET
+    uri: https://apps.fedoraproject.org/datagrepper/raw?topic=org.fedoraproject.prod.meetbot.meeting.item.help&delta=864000
+  response:
+    body:
+      string: !!binary |
+        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
+    headers:
+      appserver: [proxy06.fedoraproject.org]
+      apptime: [D=684600]
+      connection: [Keep-Alive]
+      content-encoding: [gzip]
+      content-length: ['4028']
+      content-type: [application/json]
+      date: ['Wed, 22 Mar 2017 21:58:21 GMT']
+      keep-alive: ['timeout=15, max=500']
+      server: [Apache/2.4.6 (Red Hat Enterprise Linux) mod_wsgi/3.4 Python/2.7.5]
+      strict-transport-security: [max-age=15768000; includeSubDomains; preload]
+      vary: [Accept-Encoding]
+    status: {code: 200, message: OK}
+version: 1
diff --git a/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_search_date b/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_search_date
new file mode 100644
index 0000000..49d31ac
--- /dev/null
+++ b/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_search_date
@@ -0,0 +1,98 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.13.0]
+    method: GET
+    uri: https://apps.fedoraproject.org/datagrepper/raw?topic=org.fedoraproject.prod.meetbot.meeting.item.help&delta=864000
+  response:
+    body:
+      string: !!binary |
+        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
+    headers:
+      appserver: [proxy06.fedoraproject.org]
+      apptime: [D=560197]
+      connection: [Keep-Alive]
+      content-encoding: [gzip]
+      content-length: ['4029']
+      content-type: [application/json]
+      date: ['Wed, 22 Mar 2017 19:51:05 GMT']
+      keep-alive: ['timeout=15, max=500']
+      server: [Apache/2.4.6 (Red Hat Enterprise Linux) mod_wsgi/3.4 Python/2.7.5]
+      strict-transport-security: [max-age=15768000; includeSubDomains; preload]
+      vary: [Accept-Encoding]
+    status: {code: 200, message: OK}
+version: 1
diff --git a/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_search_hub b/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_search_hub
new file mode 100644
index 0000000..c2ca0a7
--- /dev/null
+++ b/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_search_hub
@@ -0,0 +1,98 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.13.0]
+    method: GET
+    uri: https://apps.fedoraproject.org/datagrepper/raw?topic=org.fedoraproject.prod.meetbot.meeting.item.help&delta=864000
+  response:
+    body:
+      string: !!binary |
+        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
+    headers:
+      appserver: [proxy06.fedoraproject.org]
+      apptime: [D=461727]
+      connection: [Keep-Alive]
+      content-encoding: [gzip]
+      content-length: ['4029']
+      content-type: [application/json]
+      date: ['Wed, 22 Mar 2017 19:51:07 GMT']
+      keep-alive: ['timeout=15, max=500']
+      server: [Apache/2.4.6 (Red Hat Enterprise Linux) mod_wsgi/3.4 Python/2.7.5]
+      strict-transport-security: [max-age=15768000; includeSubDomains; preload]
+      vary: [Accept-Encoding]
+    status: {code: 200, message: OK}
+version: 1
diff --git a/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_search_meetingname b/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_search_meetingname
new file mode 100644
index 0000000..07ed82f
--- /dev/null
+++ b/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_search_meetingname
@@ -0,0 +1,98 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.13.0]
+    method: GET
+    uri: https://apps.fedoraproject.org/datagrepper/raw?topic=org.fedoraproject.prod.meetbot.meeting.item.help&delta=864000
+  response:
+    body:
+      string: !!binary |
+        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
+    headers:
+      appserver: [proxy04.fedoraproject.org]
+      apptime: [D=621781]
+      connection: [Keep-Alive]
+      content-encoding: [gzip]
+      content-length: ['4029']
+      content-type: [application/json]
+      date: ['Wed, 22 Mar 2017 19:36:08 GMT']
+      keep-alive: ['timeout=15, max=500']
+      server: [Apache/2.4.6 (Red Hat Enterprise Linux) mod_wsgi/3.4 Python/2.7.5]
+      strict-transport-security: [max-age=15768000; includeSubDomains; preload]
+      vary: [Accept-Encoding]
+    status: {code: 200, message: OK}
+version: 1
diff --git a/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_search_people b/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_search_people
new file mode 100644
index 0000000..d3b8371
--- /dev/null
+++ b/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_search_people
@@ -0,0 +1,98 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.13.0]
+    method: GET
+    uri: https://apps.fedoraproject.org/datagrepper/raw?topic=org.fedoraproject.prod.meetbot.meeting.item.help&delta=864000
+  response:
+    body:
+      string: !!binary |
+        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
+    headers:
+      appserver: [proxy06.fedoraproject.org]
+      apptime: [D=474498]
+      connection: [Keep-Alive]
+      content-encoding: [gzip]
+      content-length: ['4029']
+      content-type: [application/json]
+      date: ['Wed, 22 Mar 2017 19:28:44 GMT']
+      keep-alive: ['timeout=15, max=500']
+      server: [Apache/2.4.6 (Red Hat Enterprise Linux) mod_wsgi/3.4 Python/2.7.5]
+      strict-transport-security: [max-age=15768000; includeSubDomains; preload]
+      vary: [Accept-Encoding]
+    status: {code: 200, message: OK}
+version: 1
diff --git a/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_search_requesters b/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_search_requesters
new file mode 100644
index 0000000..cd075ec
--- /dev/null
+++ b/hubs/tests/vcr-request-data/hubs.tests.test_widgets.test_halp.HalpViewsTestCase.test_search_requesters
@@ -0,0 +1,194 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.13.0]
+    method: GET
+    uri: https://apps.fedoraproject.org/datagrepper/raw?topic=org.fedoraproject.prod.meetbot.meeting.item.help&delta=864000
+  response:
+    body:
+      string: !!binary |
+        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
+    headers:
+      appserver: [proxy04.fedoraproject.org]
+      apptime: [D=642014]
+      connection: [Keep-Alive]
+      content-encoding: [gzip]
+      content-length: ['4029']
+      content-type: [application/json]
+      date: ['Wed, 22 Mar 2017 22:29:51 GMT']
+      keep-alive: ['timeout=15, max=500']
+      server: [Apache/2.4.6 (Red Hat Enterprise Linux) mod_wsgi/3.4 Python/2.7.5]
+      strict-transport-security: [max-age=15768000; includeSubDomains; preload]
+      vary: [Accept-Encoding]
+    status: {code: 200, message: OK}
+- request:
+    body: null
+    headers:
+      Accept: ['*/*']
+      Accept-Encoding: ['gzip, deflate']
+      Connection: [keep-alive]
+      User-Agent: [python-requests/2.13.0]
+    method: GET
+    uri: https://apps.fedoraproject.org/datagrepper/raw?topic=org.fedoraproject.prod.meetbot.meeting.item.help&delta=864000
+  response:
+    body:
+      string: !!binary |
+        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
+    headers:
+      appserver: [proxy06.fedoraproject.org]
+      apptime: [D=442751]
+      connection: [Keep-Alive]
+      content-encoding: [gzip]
+      content-length: ['4028']
+      content-type: [application/json]
+      date: ['Wed, 22 Mar 2017 22:34:13 GMT']
+      keep-alive: ['timeout=15, max=500']
+      server: [Apache/2.4.6 (Red Hat Enterprise Linux) mod_wsgi/3.4 Python/2.7.5]
+      strict-transport-security: [max-age=15768000; includeSubDomains; preload]
+      vary: [Accept-Encoding]
+    status: {code: 200, message: OK}
+version: 1
diff --git a/hubs/widgets/halp/__init__.py b/hubs/widgets/halp/__init__.py
index 474fc83..47b47fb 100644
--- a/hubs/widgets/halp/__init__.py
+++ b/hubs/widgets/halp/__init__.py
@@ -10,6 +10,13 @@ from .views import add_view, hubs_suggest_view
 
 
 class Halp(Widget):
+    """Widget to list and query help requests.
+
+    In IRC channels, people can request help using the #halp keyword.
+    This widget will list those requests and allow searching through them.
+
+    See https://pagure.io/fedora-hubs/issue/98
+    """
 
     name = "halp"
     label = "Help Requests"
diff --git a/hubs/widgets/halp/functions.py b/hubs/widgets/halp/functions.py
index 0c30728..89cf0df 100644
--- a/hubs/widgets/halp/functions.py
+++ b/hubs/widgets/halp/functions.py
@@ -2,13 +2,13 @@ from __future__ import unicode_literals, absolute_import
 
 
 import datetime
-import flask
-import fedmsg.meta
-import re
 import logging
-import requests
+import re
 import time
 
+import flask
+import fedmsg.meta
+import requests
 from jinja2.utils import urlize
 
 from hubs.widgets.caching import CachedFunction
@@ -19,6 +19,11 @@ log = logging.getLogger('hubs.widgets')
 
 
 class GetRequests(CachedFunction):
+    """Get the help requests from Datagrepper.
+
+    This function will request recent help requests from datagrepper and cache
+    them.
+    """
 
     TOPIC = "org.fedoraproject.prod.meetbot.meeting.item.help"
 
@@ -29,7 +34,6 @@ class GetRequests(CachedFunction):
         args = {
             "topic": self.TOPIC,
             "delta": str(86400 * 10),  # 10 days
-            # "rows_per_page": per_page,
         }
         log.info("Getting request data from Datagrepper")
         response = requests.get(url, args)
@@ -48,6 +52,10 @@ class GetRequests(CachedFunction):
 
 
 def _format_message(raw_message):
+    """
+    Format a raw Fedmsg message dict into a dict that the Halp widget will be
+    able to use more directly.
+    """
     msg = {
         "date": raw_message["msg"]["details"]["time_"],
         "channel": raw_message["msg"]["channel"],
diff --git a/hubs/widgets/halp/utils.py b/hubs/widgets/halp/utils.py
index 86a5f00..4d4c210 100644
--- a/hubs/widgets/halp/utils.py
+++ b/hubs/widgets/halp/utils.py
@@ -1,5 +1,7 @@
 from __future__ import unicode_literals
 
+import flask
+
 from hubs.models import Hub, HubConfig
 
 
@@ -8,6 +10,10 @@ def find_hubs_for_msg(msg):
     Returns the hubs working in the given channel.
 
     TODO: link meeting channels to the hub using them at that time.
+
+    Args:
+        msg (dict): the ``msg`` value in the raw message dict sent through
+            Fedmsg.
     """
     return [
         h[0] for h in
@@ -15,3 +21,60 @@ def find_hubs_for_msg(msg):
             HubConfig.chat_channel == msg["channel"]
         ).values(Hub.name)
         ]
+
+
+def paginate(values, per_page):
+    """Paginate the values.
+
+    The requested page will be extracted from the ``page`` query string
+    element.
+
+    The returned value will be a tuple with two elements:
+
+    - the paginated values
+    - the page information
+
+    The page information is a dictionary with the following keys:
+
+    - ``nr``: the page number
+    - ``has_prev``: ``True`` if there is a previous page, ``False`` otherwise
+    - ``has_next``: ``True`` if there is a following page, ``False`` otherwise
+    - ``total_entries``: the total number of entries in the ``values`` argument
+    - ``total_pages``: the total number of pages
+
+    Args:
+        values (list or query): The list or SQLAlchemy query object to
+            paginate.
+        per_page (int): the number of elements per page.
+
+    Returns:
+        tuple: a tuple containing the paginated values and the page
+            information.
+    """
+    try:
+        total = values.count()
+    except TypeError:
+        total = len(values)
+    try:
+        page = flask.request.values.get("page", 1, int)
+    except ValueError:
+        page = 1
+    last_page = int(total / per_page)
+    if total > last_page * per_page:
+        last_page += 1
+    if last_page < 1:
+        last_page = 1
+    if page < 1:
+        page = 1
+    if page > last_page:
+        page = last_page
+    start = (page - 1) * per_page
+    end = start + per_page
+    page_data = {
+        "nr": page,
+        "has_prev": page > 1,
+        "has_next": page < last_page,
+        "total_entries": total,
+        "total_pages": last_page,
+    }
+    return values[start:end], page_data
diff --git a/hubs/widgets/halp/views.py b/hubs/widgets/halp/views.py
index 3c992f4..264da3d 100644
--- a/hubs/widgets/halp/views.py
+++ b/hubs/widgets/halp/views.py
@@ -1,8 +1,9 @@
 from __future__ import unicode_literals, absolute_import
 
 import datetime
-import flask
 import time
+
+import flask
 from flask.signals import template_rendered, before_render_template
 
 from hubs.models import Hub
@@ -10,40 +11,11 @@ from hubs.views.utils import get_hub
 from hubs.widgets import registry
 from hubs.widgets.base import WidgetView
 from .functions import GetRequests
-from .utils import find_hubs_for_msg
-
-
-def paginate(values, per_page):
-    try:
-        total = values.count()
-    except TypeError:
-        total = len(values)
-    try:
-        page = flask.request.values.get("page", 1, int)
-    except ValueError:
-        page = 1
-    last_page = int(total / per_page)
-    if total > last_page * per_page:
-        last_page += 1
-    if last_page < 1:
-        last_page = 1
-    if page < 1:
-        page = 1
-    if page > last_page:
-        page = last_page
-    start = (page - 1) * per_page
-    end = start + per_page
-    page_data = {
-        "nr": page,
-        "has_prev": page > 1,
-        "has_next": page < last_page,
-        "total_entries": total,
-        "total_pages": last_page,
-    }
-    return values[start:end], page_data
+from .utils import find_hubs_for_msg, paginate
 
 
 class BaseView(WidgetView):
+    """The base view to instantiate the widget."""
 
     name = "root"
     url_rules = ["/"]
@@ -57,15 +29,12 @@ class BaseView(WidgetView):
 
 
 class DataView(WidgetView):
+    """Get the requests in JSON format."""
 
     name = "data"
     url_rules = ["data"]
 
     def dispatch_request(self, *args, **kwargs):
-        """
-        The method that subclasses of :py:class:`flask.views.View` must
-        implement. It binds the other methods together.
-        """
         instance = self._get_instance(*args, **kwargs)
         get_requests = GetRequests(instance)
         data = {"requests": []}
@@ -90,16 +59,13 @@ class DataView(WidgetView):
 
 
 class SearchView(WidgetView):
+    """Search the requests and return the result as JSON."""
 
     name = "search"
     url_rules = ["search"]
     methods = ['GET', 'POST']
 
     def dispatch_request(self, *args, **kwargs):
-        """
-        The method that subclasses of :py:class:`flask.views.View` must
-        implement. It binds the other methods together.
-        """
         instance = self._get_instance(*args, **kwargs)
         get_requests = GetRequests(instance)
         # prepare filters
@@ -150,26 +116,21 @@ class SearchView(WidgetView):
 
 
 class RequestersView(WidgetView):
-    """
-    Returns suggestions for the "requesters" filter.
-    """
+    """Returns suggestions for the "requesters" filter."""
 
     name = "requesters"
     url_rules = ["requesters"]
     MAX_SUGGESTS = 5
 
     def dispatch_request(self, *args, **kwargs):
-        """
-        The method that subclasses of :py:class:`flask.views.View` must
-        implement. It binds the other methods together.
-        """
         instance = self._get_instance(*args, **kwargs)
         get_requests = GetRequests(instance)
         results = set([
             req["author"]["name"] for req in get_requests()
-            if req["author"]["name"].startswith(
-                flask.request.args.get("q"))
             ])
+        query = flask.request.args.get("q")
+        if query:
+            results = [name for name in results if name.startswith(query)]
         results = sorted(list(results))[:self.MAX_SUGGESTS]
         return flask.jsonify({"results": results})
 
diff --git a/hubs/widgets/validators.py b/hubs/widgets/validators.py
index f1dcea7..780a3d4 100644
--- a/hubs/widgets/validators.py
+++ b/hubs/widgets/validators.py
@@ -151,6 +151,13 @@ class FedorahostedProject(Validator):
 
 
 class CommaSeparatedList(Validator):
+    """Fails if the value isn't a comma-separated 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.
+    """
 
     @classmethod
     def from_string(cls, value):

