aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Tom Willemse2013-06-19 23:51:54 +0200
committerGravatar Tom Willemse2013-06-19 23:51:54 +0200
commit07fbe5ece2d326dd2027e297f3fb37f1ae9d18c8 (patch)
tree910e3cdb620f1ba36f41321042329ce954e4c709
parent024b66d4c7e2e0ffea6c14b4343eb8f89b074efd (diff)
downloadscrumelo-07fbe5ece2d326dd2027e297f3fb37f1ae9d18c8.tar.gz
scrumelo-07fbe5ece2d326dd2027e297f3fb37f1ae9d18c8.zip
Replace front-end with React
-rw-r--r--js/main.js162
-rw-r--r--js/scrumelo.js24
-rw-r--r--scrumelo.el130
3 files changed, 214 insertions, 102 deletions
diff --git a/js/main.js b/js/main.js
new file mode 100644
index 0000000..a600ccf
--- /dev/null
+++ b/js/main.js
@@ -0,0 +1,162 @@
+/** @jsx React.DOM */
+var StateIcon = React.createClass({
+ render: function() {
+ var icon_names = {"TODO": "icon-check-empty",
+ "DOING": "icon-sign-blank",
+ "DONE": "icon-check"};
+ return <i class={icon_names[this.props.state]}></i>;
+ }
+});
+
+var StoryData = React.createClass({
+ render: function() {
+ if (this.props.data) {
+ return (<div>
+ Assignee: {this.props.data.Assignee}
+ <pre>{this.props.data.content}</pre>
+ </div>);
+ }
+
+ return null;
+ }
+});
+
+var StoryRow = React.createClass({
+ render: function() {
+ // A little ugly to get a space, but I don't know of any other
+ // way.
+ var state = " " + this.props.story.state;
+ var sdata = null;
+
+ if (this.state.content)
+ sdata = <StoryData data={this.state.content} />;
+
+ return (
+ <tr>
+ <td>
+ <StateIcon state={this.props.story.state} />
+ {state}
+ </td>
+ <td>
+ <a id={this.props.story.id} onClick={this.handleClick}>
+ As a {this.props.story.role}, I
+ {this.props.story.necessity} to
+ {this.props.story.title}
+ </a>
+ <br />
+ {sdata}
+ </td>
+ </tr>
+ );
+ },
+ getInitialState: function() {
+ return {content: null};
+ },
+ handleClick: React.autoBind(function(event) {
+ if (!!this.state.content) {
+ this.setState({content: null});
+ return;
+ }
+ var self = this;
+
+ $.get('/stories/' + this.props.story.id, null,
+ function (data, textStatus, jqXHR) {
+ self.setState({content: data});
+ }, 'json');
+ })
+});
+
+var StoryTable = React.createClass({
+ loadStoriesFromServer: function() {
+ $.ajax({
+ url: this.props.url,
+ mimeType: 'textPlain',
+ success: function(data) {
+ this.setState({data: eval(data)});
+ }.bind(this)
+ });
+ },
+ getInitialState: function() {
+ return {data: []};
+ },
+ componentWillMount: function() {
+ this.loadStoriesFromServer();
+ setInterval(
+ this.loadStoriesFromServer.bind(this),
+ this.props.pollInterval
+ );
+ },
+ render: function() {
+ var storyNodes = this.state.data.map(function (story) {
+ return <StoryRow story={story} />;
+ });
+ return (
+ <table class="table table-striped">
+ {storyNodes}
+ </table>
+ );
+ }
+});
+
+var StoryForm = React.createClass({
+ handleSubmit: React.autoBind(function() {
+ var role = this.refs.role.getDOMNode().value.trim();
+ var necessity = this.refs.necessity.getDOMNode().value.trim();
+ var headline = this.refs.headline.getDOMNode().value.trim();
+
+ this.props.onStorySubmit({role: role,
+ necessity: necessity,
+ headline: headline});
+
+ this.refs.role.getDOMNode().value = '';
+ this.refs.necessity.getDOMNode().value = '';
+ this.refs.headline.getDOMNode().value = '';
+
+ return false;
+ }),
+ render: function() {
+ return (
+ <form onSubmit={this.handleSubmit}>
+ <fieldset>
+ <legend class="toggle">New story</legend>
+ <div id="new-story">
+ <div class="input-prepend input-append">
+ <span class="add-on">As a </span>
+ <input type="text" class="input-medium" ref="role" />
+ <span class="add-on"> I </span>
+ <input type="text" class="input-mini" ref="necessity" />
+ <span class="add-on"> to </span>
+ <input type="text" class="input-xxlarge" ref="headline" />
+ <button class="btn" type="submit">!</button>
+ </div>
+ </div>
+ </fieldset>
+ </form>
+ );
+ }
+});
+
+var StoryPage = React.createClass({
+ handleStorySubmit: React.autoBind(function (story) {
+ $.ajax({
+ url: "/stories/new/",
+ type: "POST",
+ data: story,
+ dataType: 'json',
+ mimeType: 'textPlain'
+ });
+ }),
+ render: function() {
+ return (
+ <div>
+ <StoryTable url="/stories/" pollInterval={5000} />
+ <StoryForm onStorySubmit={this.handleStorySubmit} />
+ </div>
+ );
+ }
+});
+
+React.renderComponent(
+ <StoryPage />,
+ document.getElementById('content')
+);
diff --git a/js/scrumelo.js b/js/scrumelo.js
deleted file mode 100644
index e408aa7..0000000
--- a/js/scrumelo.js
+++ /dev/null
@@ -1,24 +0,0 @@
-(function ($) {
- $(document).ready(function () {
- $(".hide").hide();
- $(".toggle").click(function () {
- $(document.getElementById($(this).data("show"))).toggle();
- });
- });
-})(jQuery);
-
-function get_story_info(element) {
- var id = element.id;
- var data_element = $(element).parent().find(".data");
-
- if (data_element.length > 0)
- data_element.remove();
- else
- $.get('/stories/' + id, null,
- function (data, textStatus, jqXHR) {
- $(element).after("<div class=\"data\">" +
- "Assignee: " + data.Assignee +
- "<pre>" + data.content + "</pre>" +
- "</div>");
- }, 'json');
-}
diff --git a/scrumelo.el b/scrumelo.el
index 74564f7..a6212c2 100644
--- a/scrumelo.el
+++ b/scrumelo.el
@@ -1,4 +1,4 @@
-;;; scrumelo.el --- Scrum with elnode and org-mode
+;;; scrumelo.el --- Scrum with elnode and org-mode -*- lexical-binding: t -*-
;; Copyright (C) 2013 Tom Willemse
@@ -9,6 +9,7 @@
;; A scrum web app.
+(require 'cl-lib)
(require 'elnode)
(require 'esxml)
(require 'org)
@@ -40,13 +41,21 @@
"http://code.jquery.com/jquery-2.0.0.min.js"
"The location of the jQuery JS file.")
+(defvar scrumelo-react-js-location
+ "http://cdnjs.cloudflare.com/ajax/libs/react/0.3.2/react.min.js"
+ "The location of the React JS file.")
+
+(defvar scrumelo-jsxtransformer-js-location
+ "http://cdnjs.cloudflare.com/ajax/libs/react/0.3.2/JSXTransformer.js"
+ "The location of the JSX Transformer JS file.")
+
(defmacro with-scrumelo-http-params (params httpcon &rest body)
"Bind parameters PARAMS from HTTPCON and execute BODY."
+ (declare (indent 2))
`(let (,@(mapcar (lambda (p)
`(,p (elnode-http-param ,httpcon ,(symbol-name p))))
params))
,@body))
-(put 'with-scrumelo-http-params 'lisp-indent-function 2)
(defun scrumelo--css (href)
"Return a link pointing to HREF."
@@ -66,83 +75,26 @@
"Return a list of all required JS files."
(list (scrumelo--js scrumelo-bootstrap-js-location)
(scrumelo--js scrumelo-jquery-js-location)
+ (scrumelo--js scrumelo-react-js-location)
+ (scrumelo--js scrumelo-jsxtransformer-js-location)
(scrumelo--js "js/scrumelo.js")))
-(defun scrumelo--story ()
- "Return a description of the current org heading as a scrum story."
- (format "As a %s, I %s to %s" (org-entry-get (point) "Role")
- (org-entry-get (point) "Necessity")
- (nth 4 (org-heading-components))))
-
-(defun scrumelo--task-state-class (state)
- "Return the correct icon class for STATE."
- (cdr (assoc state '(("TODO" . "icon-check-empty")
- ("DOING" . "icon-sign-blank")
- ("DONE" . "icon-check")))))
-
-(defun scrumelo--story-row ()
- "Return a table row for the current org headline."
- (let ((state (org-entry-get (point) "TODO")))
- `(tr (td (i (@ (class ,(scrumelo--task-state-class state))) "")
- " " ,state)
- (td (a (@ (id ,(org-id-get))
- (onclick "return get_story_info(this)"))
- ,(scrumelo--story))))))
-
-(defun scrumelo--maybe-story-row ()
- "If looking at a top level heading, return a table row for it."
- (when (= (car (org-heading-components)) 1)
- (scrumelo--story-row)))
-
-(defun scrumelo--inner-story-table (buffer)
- "Return the inner part of the story table for BUFFER."
- (with-current-buffer buffer
- (delq nil (org-map-entries
- 'scrumelo--maybe-story-row nil nil 'comment))))
-
-(defun scrumelo--story-table (buffer)
- "Return the story table for BUFFER."
- `(table (@ (class "table table-striped"))
- ,@(scrumelo--inner-story-table buffer)))
-
-(defun scrumelo--new-story-form ()
- "Create a form for adding new stories."
- `(form (@ (method "POST")
- (action "/stories/new/"))
- (fieldset
- (legend (@ (class "toggle")
- (data-show "new-story")) "New story")
- (div (@ (id "new-story")
- (class "hide")
- (style "text-align: center;"))
- (div (@ (class "input-prepend input-append"))
- (span (@ (class "add-on")) "As a ")
- (input (@ (class "input-medium") (type "text")
- (name "role")))
- (span (@ (class "add-on")) " I ")
- (input (@ (class "input-mini") (type "text")
- (name "necessity")))
- (span (@ (class "add-on")) " to ")
- (input (@ (class "input-xxlarge") (type "text")
- (name "headline")))
- (button (@ (class "btn") (type "submit")) "!"))))))
-
(defun scrumelo-backlog-page (httpcon)
"Send the backlog overview over HTTPCON."
- (let ((buffer (find-file-noselect scrumelo-project-file)))
- (elnode-http-start httpcon 200 '("Content-Type" . "text/html"))
- (elnode-http-return
- httpcon
- (concat
- "<!DOCTYPE html>\n"
- (sxml-to-xml
- `(html (head (title "Scrumelo")
- ,@(scrumelo--css-list)
- ,@(scrumelo--js-list))
- (body
- (div (@ (class "container"))
- ,(scrumelo--story-table buffer)
- ,(scrumelo--new-story-form)))))))))
+ (elnode-http-start httpcon 200 '("Content-Type" . "text/html"))
+ (elnode-http-return
+ httpcon
+ (concat
+ "<!DOCTYPE html>\n"
+ (sxml-to-xml
+ `(html (head (title "Scrumelo")
+ ,@(scrumelo--css-list)
+ ,@(scrumelo--js-list))
+ (body
+ (div (@ (class "container"))
+ (h1 "Backlog")
+ (div (@ (id "content")) "")
+ (script (@ (type "text/jsx") (src "js/main.js")) ""))))))))
(defun scrumelo-new-story (httpcon)
"Parse data from HTTPCON and write a new scrum story using it."
@@ -176,17 +128,39 @@
(org-end-of-meta-data-and-drawers)
(org-entry-end-position))))))))
+(defun scrumelo--org-entry-to-list ()
+ "Turn an org-entry to json."
+ (let ((components (org-heading-components)))
+ (when (= (car components) 1)
+ `((:id . ,(org-id-get))
+ (:state . ,(org-entry-get (point) "TODO"))
+ (:role . ,(org-entry-get (point) "Role"))
+ (:necessity . ,(org-entry-get (point) "Necessity"))
+ (:title . ,(nth 4 components))))))
+
+(defun scrumelo-main-json (request)
+ "Respond to REQUEST with the json info for the main page."
+ (let ((buffer (find-file-noselect scrumelo-project-file)))
+ (with-current-buffer buffer
+ (scrumelo--send-json
+ request (cl-map 'vector #'identity
+ (delq nil
+ (org-map-entries
+ #'scrumelo--org-entry-to-list
+ nil nil 'comment)))))))
+
(defun scrumelo-handler (httpcon)
"Send the right requests in HTTPCON to the right functions."
(elnode-dispatcher
httpcon
`(("^/$" . scrumelo-backlog-page)
- ("^/js/scrumelo.js" . ,(elnode-make-send-file
- (concat scrumelo--base-dir "js/scrumelo.js")))
+ ("^/js/main.js" . ,(elnode-make-send-file
+ (concat scrumelo--base-dir "js/main.js")))
+ ("^/stories/$" . scrumelo-main-json)
("^/stories/new/$" . scrumelo-new-story)
("^/stories/\\([a-z0-9:-]+\\)/$" . scrumelo-story-json))))
-(elnode-start 'scrumelo-handler :port 8028 :host "localhost")
+(elnode-start 'scrumelo-handler :port 8028 :host "0.0.0.0")
(provide 'scrumelo)
;;; scrumelo.el ends here