Initial commit

This commit is contained in:
Tom Willemse 2013-06-30 22:38:05 +02:00
commit a993573859
9 changed files with 522 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.emacs.desktop*

46
defmodule.lisp Normal file
View file

@ -0,0 +1,46 @@
(restas:define-policy datastore
(:interface-package #:scrumli.policy.datastore)
(:interface-method-template "DATASTORE-~A")
(:internal-package #:scrumli.datastore)
(define-method init ()
"Initiate the datastore.")
(define-method get-all-stories ()
"Get all of the stories in the datastore.")
(define-method get-story (id)
"Get a story from the datastore.")
(define-method post-story (role necessity title content reporter)
"Post a new story.")
(define-method story-get-state (id)
"Get the state of a story.")
(define-method story-set-state (id state)
"Set the state of a story.")
(define-method story-change-priority (id dir)
"Change the priority of a story in direction DIR."))
(restas:define-module #:scrumli
(:use #:cl #:restas #:json #:scrumli.datastore #:drakma)
(:export #:start-scrumli))
(defpackage #:scrumli.pg-datastore
(:use #:cl #:postmodern #:scrumli.policy.datastore)
(:export #:pg-datastore))
(in-package #:scrumli)
(defparameter *static-directory*
(merge-pathnames #P"static/" scrumli-config:*base-directory*))
(eval-when (:compile-toplevel :load-toplevel :execute)
(sexml:with-compiletime-active-layers
(sexml:standard-sexml sexml:xml-doctype)
(sexml:support-dtd
(merge-pathnames "html5.dtd" (asdf:system-source-directory "sexml"))
:<)))
(<:augment-with-doctype "html" "" :auto-emit-p t)

65
pg-datastore.lisp Normal file
View file

@ -0,0 +1,65 @@
(in-package #:scrumli.pg-datastore)
(defclass pg-datastore ()
((connection-spec :initarg :connection-spec
:accessor connection-spec)))
(defclass story ()
((id :col-type serial :reader story-id)
(state :col-type string :reader state :initform "TODO")
(role :col-type string :reader role :initarg :role)
(necessity :col-type string :reader necessity :initarg :necessity)
(title :col-type string :reader title :initarg :title)
(priority :col-type integer :reader priority :initarg :priority)
(content :col-type string :reader content :initarg :content)
(reporter :col-type string :reader reporter :initarg :reporter)
(assignee :col-type string :reader assignee :initarg :assignee))
(:metaclass dao-class)
(:keys id))
(defmethod datastore-init ((datastore pg-datastore))
(with-connection (connection-spec datastore)
(unless (table-exists-p 'story)
(execute (dao-table-definition 'story)))))
(defmethod datastore-get-all-stories ((datastore pg-datastore))
(with-connection (connection-spec datastore)
(query (:order-by (:select :* :from 'story) 'priority) :alists)))
(defmethod datastore-get-story ((datastore pg-datastore) id)
(with-connection (connection-spec datastore)
(query (:select :* :from 'story :where (:= 'id id)) :alist)))
(defmethod datastore-post-story
((datastore pg-datastore) role necessity title content reporter)
(format t "~s:~s:~s:~s:~s~%" role necessity title content reporter)
(with-connection (connection-spec datastore)
(let ((obj (make-instance
'story :role role :necessity necessity :title title
:priority (+ 1 (or (query (:select
(:coalesce (:max 'priority) 0)
:from 'story) :single)
0))
:content content :assignee "" :reporter reporter)))
(save-dao obj))))
(defmethod datastore-story-get-state ((datastore pg-datastore) id)
(with-connection (connection-spec datastore)
(query (:select 'state :from 'story :where (:= 'id id)) :single)))
(defmethod datastore-story-set-state ((datastore pg-datastore) id state)
(with-connection (connection-spec datastore)
(execute (:update 'story :set 'state state :where (:= 'id id)))))
(defmethod datastore-story-change-priority
((datastore pg-datastore) id dir)
(with-connection (connection-spec datastore)
(let* ((current-priority (query (:select 'priority :from 'story
:where (:= 'id id))
:single))
(next-priority (funcall (ecase dir (:up #'-) (:down #'+))
current-priority 1)))
(execute (:update 'story :set 'priority current-priority
:where (:= 'priority next-priority)))
(execute (:update 'story :set 'priority next-priority
:where (:= 'id id))))))

14
scrumli.asd Normal file
View file

@ -0,0 +1,14 @@
(defpackage #:scrumli-config (:export #:*base-directory*))
(defparameter scrumli-config:*base-directory*
(make-pathname :name nil :type nil :defaults *load-truename*))
(asdf:defsystem #:scrumli
:serial t
:description "Scrum with Lisp"
:author "Tom Willemse"
:license "AGPLv3"
:depends-on (:restas :sexml :postmodern :cl-json :drakma)
:components ((:file "defmodule")
(:file "pg-datastore")
(:file "util")
(:file "scrumli")))

153
scrumli.lisp Normal file
View file

@ -0,0 +1,153 @@
(in-package #:scrumli)
(defvar *scrumli-host* "http://localhost:8080"
"The host currently running Scrumli. Used by Mozilla Persona.")
(defvar *scrumelo-bootstrap-css-location*
"http://netdna.bootstrapcdn.com/twitter-bootstrap/2.3.2/css/bootstrap-combined.no-icons.min.css"
"The location of the twitter bootstrap CSS file.")
(defvar *scrumelo-bootstrap-js-location*
"http://netdna.bootstrapcdn.com/twitter-bootstrap/2.3.2/js/bootstrap.min.js"
"The location of the twitter bootstrap JS file.")
(defvar *scrumelo-font-awesome-css-location*
"http://netdna.bootstrapcdn.com/font-awesome/3.1.1/css/font-awesome.min.css"
"The location of the font awesome CSS file.")
(defvar *scrumelo-jquery-js-location*
"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.")
(defun logged-in-p ()
(hunchentoot:session-value :username))
(define-route main ("")
(if (logged-in-p)
(<:html
(<:head (<:title "Hello!")
(<:link :href *scrumelo-bootstrap-css-location*
:rel "stylesheet" :type "text/css")
(<:link :href *scrumelo-font-awesome-css-location*
:rel "stylesheet" :type "text/css")
(<:script :type "text/javascript"
:src *scrumelo-bootstrap-js-location*)
(<:script :type "text/javascript"
:src *scrumelo-jquery-js-location*)
(<:script :type "text/javascript"
:src *scrumelo-react-js-location*)
(<:script :type "text/javascript"
:src *scrumelo-jsxtransformer-js-location*))
(<:body
(<:a :href "/logout" "Logout") " "
(hunchentoot:session-value :username)
(<:div :class "container"
(<:h1 "Backlog")
(<:div :id "content")
(<:script :type "text/jsx" :src "js/main.js"))))
(redirect 'login-page)))
(define-route react-ui ("js/main.js"
:content-type "application/ecmascript")
(merge-pathnames "js/main.js" *static-directory*))
(define-route login-js ("js/login.js"
:content-type "application/ecmascript")
(merge-pathnames "js/login.js" *static-directory*))
(define-route stories-json ("stories" :content-type "text/json")
(if (logged-in-p)
(with-output-to-string (out)
(encode-json (get-all-stories) out))
403))
(define-route stories-new ("stories/new" :method :post)
(if (logged-in-p)
(let ((role (hunchentoot:post-parameter "role"))
(necessity (hunchentoot:post-parameter "necessity"))
(title (hunchentoot:post-parameter "headline"))
(content (hunchentoot:post-parameter "content")))
(format t "~s;~s;~s;~s;~s~%" role necessity title content
(hunchentoot:session-value :username))
(post-story role necessity title content
(hunchentoot:session-value :username))
200)
403))
(define-route stories-state ("stories/state" :method :post)
(if (logged-in-p)
(let* ((id (hunchentoot:post-parameter "id"))
(current-state (story-get-state id)))
(story-set-state id (ecase (intern current-state :scrumli)
(todo "DOING")
(doing "DONE")
(done "TODO")))
200)
403))
(define-route stories-priority ("stories/:dir" :method :post)
(if (logged-in-p)
(let* ((id (hunchentoot:post-parameter "id")))
(story-change-priority id (intern (string-upcase dir) :keyword))
200)
403))
(define-route login-page ("login")
(if (not (logged-in-p))
(<:html :lang "en"
(<:head (<:meta :charset "utf-8")
(<:title "Login")
(<:script :src "https://login.persona.org/include.js")
(<:script :src "/js/login.js"))
(<:body
(<:form :id "login-form" :method "POST" :action ""
(<:input :id "assertion-field" :type "hidden"
:name "assertion" :value ""))
(<:p (<:a :href "javascript:login()" "Login"))))
(redirect 'main)))
(define-route logout-page ("logout")
(if (logged-in-p)
(progn
(setf (hunchentoot:session-value :username) nil)))
(redirect 'login-page))
(defun json-to-string (obj)
(with-output-to-string (out)
(encode-json obj out)))
(defun verify-credentials (audience assertion)
(let ((response
(http-request "https://verifier.login.persona.org/verify"
:method :post :content-type "application/json"
:content (json-to-string
`(("assertion" . ,assertion)
("audience" . ,audience)))
:want-stream t)))
(decode-json response)))
(define-route login-page/post ("login" :method :post)
(let ((result (verify-credentials
*scrumli-host*
(hunchentoot:post-parameter "assertion"))))
(if (equal (cdr (assoc :status result)) "okay")
(progn
(hunchentoot:start-session)
(setf (hunchentoot:session-value :username)
(cdr (assoc :email result)))
(redirect 'main))
403)))
(define-route scrumelo-story ("stories/:id")
(if (logged-in-p)
(with-output-to-string (out)
(encode-json (get-story id) out))
403))

7
start Executable file
View file

@ -0,0 +1,7 @@
#!/bin/bash
sbcl --load scrumli.asd \
--eval "(asdf:operate 'asdf:load-op \"scrumli\")" \
--eval "(scrumli:start-scrumli \
:datastore-init '(:connection-spec \
(\"scrumli\" \"slash\" nil \"localhost\")))"

12
static/js/login.js Normal file
View file

@ -0,0 +1,12 @@
function login ()
{
navigator.id.get(function(assertion) {
if (assertion) {
var assertion_field =
document.getElementById("assertion-field");
assertion_field.value = assertion;
var login_form = document.getElementById("login-form");
login_form.submit();
}
});
}

215
static/js/main.js Normal file
View file

@ -0,0 +1,215 @@
/** @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.state.state;
var sdata = null;
if (this.state.content)
sdata = <StoryData data={this.state.content} />;
return (
<tr>
<td>
<i class="icon-arrow-up" onClick={this.moveUp}></i>
<i class="icon-arrow-down" onClick={this.moveDown}></i>
</td>
<td>
<span onClick={this.changeState}>
<StateIcon state={this.state.state} />
{state}
</span>
</td>
<td>
<a 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 {state: this.props.story.state,
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');
}),
changeState: React.autoBind(function(event) {
$.ajax({
url: "/stories/state",
type: "POST",
data: {'id': this.props.story.id},
dataType: 'json',
mimeType: 'textPlain',
success: function(data) {
this.setState({state: eval(data).state});
}.bind(this)
});
}),
moveUp: React.autoBind(function(event) {
$.ajax({
url: "/stories/up",
type: "POST",
data: {'id': this.props.story.id},
dataType: 'json',
mimeType: 'textPlain',
success: function (data) {
this.props.onMoved(1);
}.bind(this)
});
}),
moveDown: React.autoBind(function(event) {
$.ajax({
url: "/stories/down",
type: "POST",
data: {'id': this.props.story.id},
dataType: 'json',
mimeType: 'textPlain',
success: function (data) {
this.props.onMoved(-1);
}.bind(this)
});
})
});
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
);
},
handleMoved: React.autoBind(function(direction) {
this.loadStoriesFromServer();
}),
render: function() {
var storyNodes = this.state.data.map(function (story) {
return <StoryRow story={story} onMoved={this.handleMoved} />;
}.bind(this));
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();
var content = this.refs.content.getDOMNode().value.trim();
this.props.onStorySubmit({role: role,
necessity: necessity,
headline: headline,
content: content});
this.refs.role.getDOMNode().value = '';
this.refs.necessity.getDOMNode().value = '';
this.refs.headline.getDOMNode().value = '';
this.refs.content.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"
placeholder="person" />
<span class="add-on"> I </span>
<input type="text" class="input-small"
ref="necessity" placeholder="would like" />
<span class="add-on"> to </span>
<input type="text" class="input-xxlarge"
ref="headline" placeholder="fill in this form..." />
<button class="btn" type="submit">!</button><br />
<textarea ref="content"></textarea>
</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')
);

9
util.lisp Normal file
View file

@ -0,0 +1,9 @@
(in-package #:scrumli)
(defun start-scrumli (&key
(port 8080)
(datastore 'scrumli.pg-datastore:pg-datastore)
datastore-init)
(setf *datastore* (apply #'make-instance datastore datastore-init))
(init)
(start '#:scrumli :port port))