Initial commit
This commit is contained in:
commit
a993573859
9 changed files with 522 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
.emacs.desktop*
|
46
defmodule.lisp
Normal file
46
defmodule.lisp
Normal 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
65
pg-datastore.lisp
Normal 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
14
scrumli.asd
Normal 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
153
scrumli.lisp
Normal 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
7
start
Executable 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
12
static/js/login.js
Normal 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
215
static/js/main.js
Normal 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
9
util.lisp
Normal 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))
|
Loading…
Reference in a new issue