diff --git a/querulous/README b/querulous/README new file mode 100644 index 0000000..deca6d4 --- /dev/null +++ b/querulous/README @@ -0,0 +1,5 @@ +This advanced todo list demonstrates a modern AJAX-based web application. This is a work in progress, and we plan to add several features in the future releases. For now you can check it out to learn how to: + +- Integrate authentication, and security. +- Use AJAX and the Javascript reverse routing. +- Integrate with compiled assets - LESS CSS and CoffeeScript. \ No newline at end of file diff --git a/querulous/app/Global.scala b/querulous/app/Global.scala new file mode 100644 index 0000000..ed02fbc --- /dev/null +++ b/querulous/app/Global.scala @@ -0,0 +1,62 @@ +import play.api._ + +import models._ +import anorm._ + +object Global extends GlobalSettings { + + override def onStart(app: Application) { + InitialData.insert() + } + +} + +/** + * Initial set of data to be imported + * in the sample application. + */ +object InitialData { + + def date(str: String) = new java.text.SimpleDateFormat("yyyy-MM-dd").parse(str) + + def insert() = { + + if(User.findAll.isEmpty) { + + Seq( + User("guillaume@sample.com", "Guillaume Bort", "secret"), + User("maxime@sample.com", "Maxime Dantec", "secret"), + User("sadek@sample.com", "Sadek Drobi", "secret"), + User("erwan@sample.com", "Erwan Loisant", "secret") + ).foreach(User.create) + + Seq( + Project(Id(1), "Play framework", "Play 2.0") -> Seq("guillaume@sample.com", "maxime@sample.com", "sadek@sample.com", "erwan@sample.com"), + Project(Id(2), "Play framework", "Play 1.2.4") -> Seq("guillaume@sample.com", "erwan@sample.com"), + Project(Id(3), "Play framework", "Website") -> Seq("guillaume@sample.com", "maxime@sample.com"), + Project(Id(4), "Zenexity", "Secret project") -> Seq("guillaume@sample.com", "maxime@sample.com", "sadek@sample.com", "erwan@sample.com"), + Project(Id(5), "Zenexity", "Playmate") -> Seq("maxime@sample.com"), + Project(Id(6), "Personal", "Things to do") -> Seq("guillaume@sample.com"), + Project(Id(7), "Zenexity", "Play samples") -> Seq("guillaume@sample.com", "maxime@sample.com"), + Project(Id(8), "Personal", "Private") -> Seq("maxime@sample.com"), + Project(Id(9), "Personal", "Private") -> Seq("guillaume@sample.com"), + Project(Id(10), "Personal", "Private") -> Seq("erwan@sample.com"), + Project(Id(11), "Personal", "Private") -> Seq("sadek@sample.com") + ).foreach { + case (project,members) => Project.create(project, members) + } + + /*Seq( + Task(NotAssigned, "Todo", 1, "Fix the documentation", false, None, Some("guillaume@sample.com")), + Task(NotAssigned, "Urgent", 1, "Prepare the beta release", false, Some(date("2011-11-15")), None), + Task(NotAssigned, "Todo", 9, "Buy some milk", false, None, None), + Task(NotAssigned, "Todo", 2, "Check 1.2.4-RC2", false, Some(date("2011-11-18")), Some("guillaume@sample.com")), + Task(NotAssigned, "Todo", 7, "Finish zentask integration", true, Some(date("2011-11-15")), Some("maxime@sample.com")), + Task(NotAssigned, "Todo", 4, "Release the secret project", false, Some(date("2012-01-01")), Some("sadek@sample.com")) + ).foreach(Task.create)*/ + + } + + } + +} \ No newline at end of file diff --git a/querulous/app/assets/javascripts/main.coffee b/querulous/app/assets/javascripts/main.coffee new file mode 100644 index 0000000..f096d97 --- /dev/null +++ b/querulous/app/assets/javascripts/main.coffee @@ -0,0 +1,420 @@ +# ----------------------------------------------- +# MAIN +# ----------------------------------------------- +# DISCLAMER : +# If you're used to Backbone.js, you may be +# confused by the absence of models, but the goal +# of this sample is to demonstrate some features +# of Play including the template engine. +# I'm not using client-side templating nor models +# for this purpose, and I do not recommend this +# behavior for real life projects. +# ----------------------------------------------- + +# Just a log helper +log = (args...) -> + console.log.apply console, args if console.log? + +# ------------------------------- DROP DOWN MENUS +$(".options dt, .users dt").live "click", (e) -> + e.preventDefault() + if $(e.target).parent().hasClass("opened") + $(e.target).parent().removeClass("opened") + else + $(e.target).parent().addClass("opened") + $(document).one "click", -> + $(e.target).parent().removeClass("opened") + false + +# --------------------------------- EDIT IN PLACE +$.fn.editInPlace = (method, options...) -> + this.each -> + methods = + # public methods + init: (options) -> + valid = (e) => + newValue = @input.val() + options.onChange.call(options.context, newValue) + cancel = (e) => + @el.show() + @input.hide() + @el = $(this).dblclick(methods.edit) + @input = $("") + .insertBefore(@el) + .keyup (e) -> + switch(e.keyCode) + # Enter key + when 13 then $(this).blur() + # Escape key + when 27 then cancel(e) + .blur(valid) + .hide() + edit: -> + @input + .val(@el.text()) + .show() + .focus() + .select() + @el.hide() + close: (newName) -> + @el.text(newName).show() + @input.hide() + # jQuery approach: http://docs.jquery.com/Plugins/Authoring + if ( methods[method] ) + return methods[ method ].apply(this, options) + else if (typeof method == 'object') + return methods.init.call(this, method) + else + $.error("Method " + method + " does not exist.") + +# ---------------------------------------- DRAWER +class Drawer extends Backbone.View + initialize: -> + $("#newGroup").click @addGroup + # HTML is our model + @el.children("li").each (i,group) -> + new Group + el: $(group) + $("li",group).each (i,project) -> + new Project + el: $(project) + addGroup: -> + jsRoutes.controllers.Projects.addGroup().ajax + success: (data) -> + _view = new Group + el: $(data).appendTo("#projects") + _view.el.find(".groupName").editInPlace("edit") + error: (err) -> + # TODO: Deal with + +# ---------------------------------------- GROUPS +class Group extends Backbone.View + events: + "click .toggle" : "toggle" + "click .newProject" : "newProject" + "click .deleteGroup" : "deleteGroup" + initialize: -> + @id = @el.attr("data-group") + @name = $(".groupName", @el).editInPlace + context: this + onChange: @renameGroup + newProject: (e) -> + e.preventDefault() + @el.removeClass("closed") + jsRoutes.controllers.Projects.add().ajax + context: this + data: + group: @el.attr("data-group") + success: (tpl) -> + _list = $("ul",@el) + _view = new Project + el: $(tpl).appendTo(_list) + _view.el.find(".name").editInPlace("edit") + error: (err) -> + $.error("Error: " + err) + deleteGroup: (e) -> + e.preventDefault() + false if (!confirm "Remove group and projects inside?") + id = @el.attr("data-group-id") + @loading(true) + jsRoutes.controllers.Projects.deleteGroup(@id).ajax + context: this + success: -> + @el.remove() + @loading(false) + error: (err) -> + @loading(false) + $.error("Error: " + err) + renameGroup: (name) => + @loading(true) + jsRoutes.controllers.Projects.renameGroup(@id).ajax + context: this + data: + name: name + success: (data) -> + @loading(false) + @name.editInPlace("close", data) + @el.attr("data-group", data) + @id = @el.attr("data-group") + error: (err) -> + @loading(false) + $.error("Error: " + err) + toggle: (e) -> + e.preventDefault() + @el.toggleClass("closed") + false + loading: (display) -> + if (display) + @el.children(".options").hide() + @el.children(".loader").show() + else + @el.children(".options").show() + @el.children(".loader").hide() + +# --------------------------------------- PROJECT +class Project extends Backbone.View + events: + "click .delete" : "deleteProject" + initialize: -> + @id = @el.attr("data-project") + @name = $(".name", @el).editInPlace + context: this + onChange: @renameProject + renameProject: (name) -> + @loading(true) + jsRoutes.controllers.Projects.rename(@id).ajax + context: this + data: + name: name + success: (data) -> + @loading(false) + @name.editInPlace("close", data) + error: (err) -> + @loading(false) + $.error("Error: " + err) + deleteProject: (e) -> + e.preventDefault() + @loading(true) + jsRoutes.controllers.Projects.delete(@id).ajax + context: this + success: -> + @el.remove() + @loading(false) + error: (err) -> + @loading(false) + $.error("Error: " + err) + false + loading: (display) -> + if (display) + @el.children(".delete").hide() + @el.children(".loader").show() + else + @el.children(".delete").show() + @el.children(".loader").hide() + +# ---------------------------------------- ROUTER +class AppRouter extends Backbone.Router + initialize: -> + @currentApp = new Tasks + el: $("#main") + routes: + "" : "index" + "/projects/:project/tasks" : "tasks" + index: -> + # show dashboard + $("#main").load "/ #main" + tasks: (project) -> + # load project || display app + currentApp = @currentApp + $("#main").load "/projects/" + project + "/tasks", (tpl) -> + currentApp.render(project) + +# ----------------------------------------- TASKS +class Tasks extends Backbone.View + events: + "click .newFolder" : "newFolder" + "click .list .action" : "removeUser" + "click .addUserList .action" : "addUser" + render: (project) -> + @project = project + # HTML is our model + @folders = $.map $(".folder", @el), (folder) => + new TaskFolder + el: $(folder) + project: @project + newFolder: (e) -> + e.preventDefault() + jsRoutes.controllers.Tasks.addFolder(@project).ajax + context: this + success: (tpl) -> + newFolder = new TaskFolder + el: $(tpl).insertBefore(".newFolder") + project: @project + newFolder.el.find("header > h3").editInPlace("edit") + error: (err) -> + $.error("Error: " + err) + false + removeUser: (e) -> + e.preventDefault() + jsRoutes.controllers.Projects.removeUser(@project).ajax + context: this + data: + user: $(e.target).parent().data('user-id') + success: -> + $(e.target).parent().appendTo(".addUserList") + error: (err) -> + $.error("Error: " + err) + false + addUser: (e) -> + e.preventDefault() + jsRoutes.controllers.Projects.addUser(@project).ajax + context: this + data: + user: $(e.target).parent().data('user-id') + success: -> + $(e.target).parent().appendTo(".users .list") + error: (err) -> + $.error("Error: " + err) + false + +# ---------------------------------- TASKS FOLDER +class TaskFolder extends Backbone.View + events: + "click .deleteCompleteTasks" : "deleteCompleteTasks" + "click .deleteAllTasks" : "deleteAllTasks" + "click .deleteFolder" : "deleteFolder" + "change header>input" : "toggleAll" + "submit .addTask" : "newTask" + initialize: (options) => + @project = options.project + @tasks = $.map $(".list li",@el), (item)=> + newTask = new TaskItem + el: $(item) + folder: @ + newTask.bind("change", @refreshCount) + newTask.bind("delete", @deleteTask) + @counter = @el.find(".counter") + @id = @el.attr("data-folder-id") + @name = $("header > h3", @el).editInPlace + context: this + onChange: @renameFolder + @refreshCount() + newTask: (e) => + e.preventDefault() + $(document).focus() # temporary disable form + form = $(e.target) + taskBody = $("input[name=taskBody]", form).val() + url = form.attr("action") + jsRoutes.controllers.Tasks.add(@project, @id).ajax + url: url + type: "POST" + context: this + data: + title: $("input[name=taskBody]", form).val() + dueDate: $("input[name=dueDate]", form).val() + assignedTo: $("input[name=assignedTo]", form).val() + success: (tpl) -> + newTask = new TaskItem(el: $(tpl), folder: @) + @el.find("ul").append(newTask.el) + @tasks.push(newTask) + form.find("input[type=text]").val("").first().focus() + error: (err) -> + alert "Something went wrong:" + err + false + renameFolder: (name) => + @loading(true) + jsRoutes.controllers.Tasks.renameFolder(@project, @id).ajax + context: this + data: + name: name + success: (data) -> + @loading(false) + @name.editInPlace("close", data) + @el.attr("data-folder-id", data) + @id = @el.attr("data-folder-id") + error: (err) -> + @loading(false) + $.error("Error: " + err) + deleteCompleteTasks: (e) => + e.preventDefault() + $.each @tasks, (i, item) -> + item.deleteTask() if item.el.find(".done:checked").length > 0 + true + false + deleteAllTasks: (e) => + e.preventDefault() + $.each @tasks, (i, item)-> + item.deleteTask() + true + false + deleteFolder: (e) => + e.preventDefault() + @el.remove() + false + toggleAll: (e) => + val = $(e.target).is(":checked") + $.each @tasks, (i, item) -> + item.toggle(val) + true + refreshCount: => + count = @tasks.filter((item)-> + item.el.find(".done:checked").length == 0 + ).length + @counter.text(count) + deleteTask: (task) => + @tasks = _.without @tasks, tasks + @refreshCount() + loading: (display) -> + if (display) + @el.find("header .options").hide() + @el.find("header .loader").show() + else + @el.find("header .options").show() + @el.find("header .loader").hide() + +# ------------------------------------- TASK ITEM +class TaskItem extends Backbone.View + events: + "change .done" : "onToggle" + "click .deleteTask" : "deleteTask" + "dblclick h4" : "editTask" + initialize: (options) -> + @check = @el.find(".done") + @id = @el.attr("data-task-id") + @folder = options.folder + deleteTask: (e) => + e.preventDefault() if e? + @loading(false) + jsRoutes.controllers.Tasks.delete(@id).ajax + context: this + data: + name: name + success: (data) -> + @loading(false) + @el.remove() + @trigger("delete", @) + error: (err) -> + @loading(false) + $.error("Error: " + err) + false + editTask: (e) => + e.preventDefault() + # TODO + alert "not implemented yet." + false + toggle: (val) => + @loading(true) + jsRoutes.controllers.Tasks.update(@id).ajax + context: this + data: + done: val + success: (data) -> + @loading(false) + @check.attr("checked",val) + @trigger("change", @) + error: (err) -> + @loading(false) + $.error("Error: " + err) + onToggle: (e) => + e.preventDefault() + val = @check.is(":checked") + log val + @toggle(val) + false + loading: (display) -> + if (display) + @el.find(".delete").hide() + @el.find(".loader").show() + else + @el.find(".delete").show() + @el.find(".loader").hide() + +# ------------------------------------- INIT APP +$ -> # document is ready! + + app = new AppRouter() + drawer = new Drawer el: $("#projects") + + Backbone.history.start + pushHistory: true + diff --git a/querulous/app/assets/stylesheets/apps/_tasks.less b/querulous/app/assets/stylesheets/apps/_tasks.less new file mode 100644 index 0000000..fdd28ab --- /dev/null +++ b/querulous/app/assets/stylesheets/apps/_tasks.less @@ -0,0 +1,137 @@ +// ----------------------------------- +// TASKS +// ----------------------------------- +// author: mda@zenexity.com - 2011 +// ----------------------------------- + +.tasks { + .folder { + .box(); + > header { + position: relative; + padding: 4px 25px 4px 7px; + input { + display: inline-block; + } + input[type=text] { + margin: 1px 0 0; + } + .options, .loader { + position: absolute; + top: 4px; + right: 4px; + } + } + > ul { + > li { + position: relative; + padding: 4px 20px 4px 7px; + border-bottom: 1px solid rgba(0,0,0,.05); + -webkit-user-select: text; + time { + float: right; + display: inline-block; + margin: 0 10px; + border-radius: 15px; + background: url(/assets/images/icons/clock.png) 1px 1px no-repeat; + background-color: @dueDateBackground; + border: 1px solid @dueDateBorder; + color: @dueDateColor; + font-size: 11px; + padding: 0 4px 1px 15px; + } + h4 { + display: inline-block; + font-weight: bold; + } + .deleteTask { + .delete(); + top: 4px; + right: 4px; + } + &:hover .deleteTask { + opacity: 1; + -webkit-transition-delay: 0; + } + .assignedTo { + float: right; + display: inline-block; + margin: 0 10px; + padding-left: 17px; + color: @assignedToColor; + background: url(/assets/images/icons/user2.png) 0 1px no-repeat; + } + } + } + .addTask { + position: relative; + border-radius: 0 0 4px 4px; + padding: 5px 250px 5px 5px; + background: white; + &:after { + content: " "; + display: block; + clear: both; + } + input { + outline: none; + } + [name=taskBody] { + width: 100%; + border: 0; + } + .dueDate { + position: absolute; + right: 210px; + top: 4px; + width: 140px; + border-radius: 15px; + background: url(/assets/images/icons/clock.png) 2px 2px no-repeat; + background-color: @dueDateBackground; + border: 1px solid @dueDateBorder; + color: @dueDateColor; + font-size: 11px; + padding: 1px 4px 1px 15px; + &::-webkit-input-placeholder { + color: inherit; + } + &:-moz-placeholder { + color: inherit; + } + } + .assignedTo { + position: absolute; + right: 5px; + top: 5px; + width: 195px; + input { + width: 180px; + margin: 2px; + border: 0; + } + } + .assignToList { + display: none; + position: absolute; + top: 100%; + right: 0; + min-width: 100%; + background: rgba(0,0,0,.8); + color: #eee; + } + [type=submit] { + position: absolute; + left: -3000px; + visibility: hidden; + } + ul, div { + display: inline-block; + } + } + .loader { + position: absolute; + top: 5px; + right: 6px; + } + } +} diff --git a/querulous/app/assets/stylesheets/libs/_mate.less b/querulous/app/assets/stylesheets/libs/_mate.less new file mode 100644 index 0000000..cf06688 --- /dev/null +++ b/querulous/app/assets/stylesheets/libs/_mate.less @@ -0,0 +1,43 @@ +// ----------------------------------- +// MATE: CSS helpers +// ----------------------------------- +// author: mda@zenexity.com - 2011 +// ----------------------------------- + +// ------------------------ GRADIENTS +.gradient(@from:#000, @to:#EEE) { + background: @from; + background-image: -webkit-gradient(linear, left top, left bottom, from(@from), to(@to)); + background-image: -moz-linear-gradient(top, @from, @to); +} + +// ---------------------- TRANSITIONS +.transition(@range: all, @time: 500ms, @ease: ease-in-out) { + -moz-transition: @range @time @ease; + -webkit-transition: @range @time @ease; + -o-transition: @range @time @ease; + transition: @range @time @ease; +} + +// ------------------------ TRANSFORMS +.transform(@props) { + -moz-transform: @arguments; + -webkit-transform: @arguments; + -o-transform: @arguments; + transform: @arguments; +} + +// --------------------------- HELPERS +.ellipsis() { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.clear() { + &:after { + display: block; + content: " "; + clear: both; + } +} diff --git a/querulous/app/assets/stylesheets/libs/_reset.less b/querulous/app/assets/stylesheets/libs/_reset.less new file mode 100644 index 0000000..dc347d5 --- /dev/null +++ b/querulous/app/assets/stylesheets/libs/_reset.less @@ -0,0 +1,12 @@ +html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, font, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td {margin: 0; padding: 0; border: 0; outline: 0; font-weight: inherit; font-style: inherit; font-size: 100%; font-family: inherit;} + +table {border-collapse: collapse; border-spacing: 0;} +caption, th, td {text-align: left; font-weight: normal;} +form legend {display: none;} +blockquote:before, blockquote:after, q:before, q:after {content: "";} +blockquote, q {quotes: "" "";} +ol, ul {list-style: none;} +hr {display: none; visibility: hidden;} + + + diff --git a/querulous/app/assets/stylesheets/libs/_theme.less b/querulous/app/assets/stylesheets/libs/_theme.less new file mode 100644 index 0000000..97f957c --- /dev/null +++ b/querulous/app/assets/stylesheets/libs/_theme.less @@ -0,0 +1,56 @@ +// ----------------------------------- +// THEME +// ----------------------------------- +// author: mda@zenexity.com - 2011 +// ----------------------------------- + +// ---------------------------- COLORS +@mainBackground: #c7d1d8; +@mainColor: #2a3a48; +@drawerBackground: #e1e7ec; +@folderColor: #6f8193; +@projectColor: @mainColor; +@layoutBorderColor: #606d78; + +@links: #4794c4; +@linksHover: #2a5e88; + +@headerLight: #445868; +@headerDark: @mainColor; +@titleColor: #556b7b; +@buttonColor: @mainBackground; +@buttonHover: #fff; + +@activeColor: #5daad5; + +// Tasks +@dueDateBackground: #accfe8; +@dueDateBorder: #73a4ca; +@dueDateColor: #246fa9; +@assignedToColor: @titleColor; + +// Folders +@folderBorder: #9ba5ad; +@folderBackground: #dbe1e5; +@folderHeaderLight: #f2f5f8; +@folderHeaderDark: #dfe2e6; +@folderTitle: #7a8a99; + +// Pannels +@panBackground: rgba(0,0,0,.85); +@panShadow: rgba(0,0,0,.8); +@panText: #aaa; +@panTitle: #fff; +@panBorder: #333; +@panButtonBackground: #444; +@panButtonColor: #fff; + +// ----------------------------- FONTS +@defaultFont: 13px "Lucida Grande","Helvetica Neue", sans-serif; + +// ----------------------------- SIZES +@headerHeight: 40px; +@drawerWidth: 220px; +@navigationWidth: 70px; +@breadcrumbHeight: 50px; + diff --git a/querulous/app/assets/stylesheets/login.less b/querulous/app/assets/stylesheets/login.less new file mode 100644 index 0000000..ae5b2c7 --- /dev/null +++ b/querulous/app/assets/stylesheets/login.less @@ -0,0 +1,100 @@ +// ----------------------------------- +// LOGIN +// ----------------------------------- +// author: mda@zenexity.com - 2011 +// ----------------------------------- + +@import 'libs/_reset.less'; +@import 'libs/_mate.less'; +@import 'libs/_theme.less'; + +// ----------------------- MAIN STYLES +body { + background: @mainBackground; + color: @mainColor; + font: @defaultFont; + -webkit-font-smoothing: antialised; + -webkit-user-select: none; +} + +a { + color: @links; + text-decoration: none; + &:hover { + color: @linksHover; + } +} + +p.note { + margin: 10px auto 0; + width: 300px; + padding: 20px; + text-align: center; + color: #445868; + text-shadow: 1px 1px rgba(255,255,255,.6); + em { + font-weight: bold; + } +} + +form { + margin: 100px auto 0; + width: 300px; + padding: 20px; + background: #fff; + border-radius: 5px; + box-shadow: 0 2px 5px rgba(0,0,0,.3); + text-align: center; + h1 { + margin: 0 0 10px; + font-size: 20px; + font-weight: bold; + } + p { + width: inherit; + margin: 5px 0; + color: #999; + } + p.error { + color: #c00; + margin-bottom: 10px; + text-shadow: 1px 1px rgba(0,0,0,.1); + } + p.success { + color: #83BD41; + margin-bottom: 10px; + text-shadow: 1px 1px rgba(0,0,0,.1); + } + input { + display: block; + width: inherit; + padding: 2px; + background: rgba(0,0,0,.05); + border: 1px solid rgba(0,0,0,.15); + box-shadow: 0 1px 2px rgba(0,0,0,.1) inset; + border-radius: 3px; + font-size: 14px; + &:invalid:not(:focus) { + border-color: red; + } + } + button { + .button(); + display: block; + width: inherit; + font-size: 14px; + } +} + +nav { + margin-top: 15px; + a { + display: inline-block; + margin: 0 4px; + } +} + +// --------------------------- IMPORTS +@import 'main/_widgets.less'; // Some shared elements +@import 'main/_header.less'; // Top header + User bar + diff --git a/querulous/app/assets/stylesheets/main.less b/querulous/app/assets/stylesheets/main.less new file mode 100644 index 0000000..057ae1e --- /dev/null +++ b/querulous/app/assets/stylesheets/main.less @@ -0,0 +1,35 @@ +// ----------------------------------- +// MAIN +// ----------------------------------- +// author: mda@zenexity.com - 2011 +// ----------------------------------- + +@import 'libs/_reset.less'; +@import 'libs/_mate.less'; +@import 'libs/_theme.less'; + +// ----------------------- MAIN STYLES +body { + background: @mainBackground; + color: @mainColor; + font: @defaultFont; + -webkit-font-smoothing: antialised; + -webkit-user-select: none; +} + +a { + color: @links; + text-decoration: none; + &:hover { + color: @linksHover; + } +} + +// --------------------------- IMPORTS +@import 'main/_layout.less'; // General grid +@import 'main/_widgets.less'; // Some shared elements +@import 'main/_drawer.less'; // Project drawer +@import 'main/_header.less'; // Top header + User bar +@import 'main/_breadcrumb.less'; // Breadcrumb + App menu +@import 'apps/_tasks.less'; // Tasks + diff --git a/querulous/app/assets/stylesheets/main/_breadcrumb.less b/querulous/app/assets/stylesheets/main/_breadcrumb.less new file mode 100644 index 0000000..c8bde04 --- /dev/null +++ b/querulous/app/assets/stylesheets/main/_breadcrumb.less @@ -0,0 +1,110 @@ +// ----------------------------------- +// BREADCRUMB +// ----------------------------------- +// author: mda@zenexity.com - 2011 +// ----------------------------------- + +#main > header { + height: 39px; + background: url(/assets/images/breadcrumb.png); + border-bottom: 1px solid @layoutBorderColor; + hgroup { + height: inherit; + overflow: hidden; + float: left; + > * { + float: inherit; + position: relative; + height: inherit; + line-height: 40px; + margin-left: -25px; + padding: 0 6px 0 13px; + font-size: 18px; + text-shadow: 1px 1px 0 rgba(255,255,255,.5); + -webkit-border-image: url(/assets/images/breadcrumb-2.png) 0 30 0 20 stretch stretch; + -moz-border-image: url(/assets/images/breadcrumb-2.png) 0 30 0 20 stretch stretch; + border-image: url(/assets/images/breadcrumb-2.png) 0 30 0 20 stretch stretch; + border-width: 0 30px 0 25px; + color: @titleColor; + -webkit-user-select: text; + &:nth-child(2) { + -webkit-border-image: url(/assets/images/breadcrumb-1.png) 0 30 0 1 stretch stretch; + -moz-border-image: url(/assets/images/breadcrumb-1.png) 0 30 0 1 stretch stretch; + border-image: url(/assets/images/breadcrumb-1.png) 0 30 0 2 stretch stretch; + } + &:first-child { + padding-left: 20px; + } + &:nth-child(1) { z-index: 3; } + &:nth-child(2) { z-index: 2; } + &:nth-child(3) { z-index: 1; } + } + } + .users { + position: relative; + margin: 8px 10px; + float: right; + .pannel(); + > dt { + &:before { + content: url(/assets/images/icons/user.png); + padding-right: 4px; + vertical-align: middle; + } + .button(); + } + > dd { + padding: 5px 10px; + width: 300px; + color: @panText; + z-index: 99; + } + .wrap { + overflow: auto; + max-height: 350px; + width: inherit; + } + h3 { + margin: 10px 0 0; + padding: 5px 0; + font-size: 16px; + color: @panTitle; + &:first-of-type { + margin: 0; + } + } + dl { + position: relative; + border-top: 1px solid @panBorder; + padding: 5px 17px 5px 0; + dt { + .ellipsis(); + span { + opacity: .5; + font-size: 11px; + } + } + } + .action { + position: absolute; + top: 5px; + right: 0px; + width: 16px; + height: 16px; + overflow: hidden; + text-indent: -99em; + background: #444 url(/assets/images/icons/addRemove.png) 0 1 no-repeat; + border-radius: 10px; + &:hover { + background-color: green; + } + } + .list .action { + background-position: 0 -19px; + &:hover { + background-color: red; + } + } + } +} + diff --git a/querulous/app/assets/stylesheets/main/_drawer.less b/querulous/app/assets/stylesheets/main/_drawer.less new file mode 100644 index 0000000..4cc46c7 --- /dev/null +++ b/querulous/app/assets/stylesheets/main/_drawer.less @@ -0,0 +1,130 @@ +// ----------------------------------- +// PROJECTS DRAWER +// ----------------------------------- +// author: mda@zenexity.com - 2011 +// ----------------------------------- +body { + & > nav { + padding: 10px 0 0; + background: @drawerBackground; + border-right: 1px solid @layoutBorderColor; + } + .dashboard { + display: block; + position: relative; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + padding: 10px 5px 5px 25px; + background: url(/assets/images/icons/home.png) 6px 7px no-repeat; + a { + color: @folderColor; + cursor: default; + } + } + h4 { + cursor: default; + } +} + +#projects { + input { + margin: 0; + padding: 0; + border: 0; + font: inherit; + } + > li { + position: relative; + padding: 10px 5px 5px 25px; + .options { + position: absolute; + top: 7px; + right: 5px; + button { + border: none; + } + } + .loader { + top: 8px; + right: 6px; + } + li { + position: relative; + padding: 0 0 0 25px; + background: url(/assets/images/icons/folder.png) 1px 1px no-repeat; + a { + display: block; + color: @projectColor; + padding: 2px; + } + input[type=text] { + padding: 2px 1px; + } + .delete { + opacity: 0.1; + } + .loader { + top: 2px; + right: 2px; + } + &:hover { + a { + color: #000; + } + .delete { + opacity: 1; + //-webkit-transition-delay: 0; + } + } + } + .toggle { + content: " "; + position: absolute; + left: 8px; + top: 12px; + display: inline-block; + width: 12px; + height: 12px; + vertical-align: middle; + background: url(/assets/images/icons/drawer.folder.png) 0 0 no-repeat; + } + > h4 { + display: block; + margin: 0 0 5px; + padding: 0 1px; + position: relative; + color: @folderColor; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + } + > input { + margin: 0 0 5px; + text-transform: uppercase; + font-weight: bold; + font-size: 11px; + } + &.closed { + >ul { + display: none; + } + .toggle { + background-position: 0 -20px; + } + } + } +} + +#activity { + position: absolute; + bottom: 0; + font-size: 11px; + padding: 10px; +} + +#newGroup { + font-size: 13px; + .new(); + margin: 15px 25px; +} diff --git a/querulous/app/assets/stylesheets/main/_header.less b/querulous/app/assets/stylesheets/main/_header.less new file mode 100644 index 0000000..dbaee12 --- /dev/null +++ b/querulous/app/assets/stylesheets/main/_header.less @@ -0,0 +1,50 @@ +// ----------------------------------- +// HEADER +// ----------------------------------- +// author: mda@zenexity.com - 2011 +// ----------------------------------- + +body { + & > header { + box-sizing: border-box; + padding: 10px; + z-index: 9; + .gradient(@headerLight, @headerDark); + box-shadow: 0 0 7px rgba(0,0,0,.8), + inset 0 1px 0 rgba(255,255,255,.2), + inset 0 -1px 0 rgba(0,0,0,.8); + } +} + +#logo { + color: #fff; + font-weight: bold; + font-size: 16px; + text-transform: uppercase; + letter-spacing: -2px; + text-shadow: 1px 1px 0 #000; + span { + color: @activeColor; + } +} + +#user { + position: absolute; + right: 10px; + top: 10px; + > * { + display: inline-block; + } + dt { + color: #fff; + span { + opacity: .5; + font-size: 11px; + } + } + dd { + a , button { + .button(); + } + } +} diff --git a/querulous/app/assets/stylesheets/main/_layout.less b/querulous/app/assets/stylesheets/main/_layout.less new file mode 100644 index 0000000..046c70c --- /dev/null +++ b/querulous/app/assets/stylesheets/main/_layout.less @@ -0,0 +1,48 @@ +// ----------------------------------- +// MAIN LAYOUT +// ----------------------------------- +// author: mda@zenexity.com - 2011 +// ----------------------------------- + +body { + & > header { + position: absolute; + top: 0; + left: 0; + right: 0; + height: @headerHeight; + } + & > nav { + position: absolute; + top: @headerHeight; + left: 0; + width: @drawerWidth; + bottom: 0; + box-sizing: border-box; + } + & > section { + position: absolute; + top: @headerHeight; + left: @drawerWidth; + right: 0; + bottom: 0; + } +} + +#main > header { + position: absolute; + top: 0; + left: 0; + right: 0; + height: @breadcrumbHeight; +} + +#main > article { + position: absolute; + top: 40px; + left: 0; + bottom: 0; + right: 0; + padding: 20px; + overflow: auto; +} diff --git a/querulous/app/assets/stylesheets/main/_widgets.less b/querulous/app/assets/stylesheets/main/_widgets.less new file mode 100644 index 0000000..ada18d0 --- /dev/null +++ b/querulous/app/assets/stylesheets/main/_widgets.less @@ -0,0 +1,167 @@ +// ----------------------------------- +// WIDGETS +// ----------------------------------- +// author: mda@zenexity.com - 2011 +// ----------------------------------- + +// --------------------------- OPTIONS +.pannel(){ + > dd { + //display: none; + .transform(scale(.001)); + .transition(-webkit-transform, 200ms, ease-out); + -webkit-transform-origin: right top; + -moz-transform-origin: right top; + transform-origin: right top; + position: absolute; + top: 100%; + right: -2px; + width: 150px; + margin: 7px 0px 0 0; + padding: 5px; + z-index: 99; + background: @panBackground; + border-radius: 5px; + box-shadow: 0 2px 7px @panShadow; + a, button { + display: block; + width: 100%; + padding: 3px 10px; + color: @panText; + border-radius: 2px; + background: none; + &:hover { + background: @panButtonBackground; + color: @panButtonColor; + } + } + &:before { + content: " "; + display: block; + border-bottom: 6px solid @panBackground; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: none; + margin-right: 0; + margin-top: -12px; + position: absolute; + right: 5px; + width: 1px; + height: 1px; + } + } + &.opened > dd { + .transform(scale(1)); + } +} + +.button() { + padding: 2px 5px; + border-radius: 3px; + border: 1px solid @headerDark - #111; + .gradient(fadeout(#bff, 80%), fadeout(#bff, 100%)); + background-color: @headerDark; + box-shadow: 0 1px 4px rgba(0,0,0,.3), + inset 0 1px 0 rgba(255,255,255,.2), + inset 0 -1px 0 rgba(0,0,0,.2); + text-shadow: -1px -1px 0 rgba(0,0,0,.3); + color: @buttonColor; + cursor: pointer; + &:hover { + .gradient(fadeout(#bff, 70%), fadeout(#bff, 100%)); + background-color: @headerDark; + color: @buttonHover; + } +} + +.box() { + border: 1px solid @folderBorder; + background: @folderBackground; + border-radius: 5px; + margin: 0 0 20px; + > header { + border-radius: 5px 5px 0 0; + .gradient(@folderHeaderLight, @folderHeaderDark); + border-bottom: inherit; + padding: 4px 7px; + h3 { + display: inline-block; + font-size: 15px; + font-weight: bold; + color: @folderTitle; + text-shadow: 1px 1px 0 #fff; + } + } +} +.options { + width: 20px; + height: 20px; + position: relative; + font-size: 11px; + dt { + width: inherit; + cursor: pointer; + height: inherit; + text-indent: -9999em; + background: url(/assets/images/icons/options.png) 0 0 no-repeat; + } + &:hover dt, &.opened dt, { + background: url(/assets/images/icons/options.png) 0 -20px no-repeat; + } + .pannel(); +} + +.new { + display: inline-block; + .button(); + &:before { + content: "+"; + font-size: 15px; + line-height: 15px; + font-weight: bold; + color: @activeColor; + padding-right: 4px; + } +} + +.delete { + position: absolute; + top: 0px; + right: 0; + border: none; + padding: 0; + width: 18px; + height: 20px; + overflow: hidden; + text-indent: -99em; + background: url(/assets/images/icons/delete.png) 0 1px no-repeat; + .transition(opacity, 300ms, ease-in-out); + -webkit-transition-delay: 100ms; + z-index: 7; + cursor: pointer; + &:hover { + background: url(/assets/images/icons/delete.png) 0 -19px no-repeat; + } +} + +.counter { + color: #888; + font-size: 11px; + text-shadow: 1px 1px 0 rgba(255,255,255,.7); + margin: 2px 5px; + padding: 0px 3px; + background: rgba(0,0,0,.05); + border-radius: 10px; + border: 1px solid rgba(0,0,0,.15); +} + +.loader { + display: none; + overflow: hidden; + text-indent: -99em; + position: absolute; + height: 16px; + width: 16px; + background: url(/assets/images/loading.gif); +} + diff --git a/querulous/app/controllers/Application.scala b/querulous/app/controllers/Application.scala new file mode 100644 index 0000000..856b3b4 --- /dev/null +++ b/querulous/app/controllers/Application.scala @@ -0,0 +1,114 @@ +package controllers + +import play.api._ +import play.api.mvc._ +import play.api.data._ +import play.api.data.Forms._ + +import models._ +import views._ + +object Application extends Controller { + + // -- Authentication + + val loginForm = Form( + tuple( + "email" -> text, + "password" -> text + ) verifying ("Invalid email or password", result => result match { + case (email, password) => User.authenticate(email, password).isDefined + }) + ) + + /** + * Login page. + */ + def login = Action { implicit request => + Ok(html.login(loginForm)) + } + + /** + * Handle login form submission. + */ + def authenticate = Action { implicit request => + loginForm.bindFromRequest.fold( + formWithErrors => BadRequest(html.login(formWithErrors)), + user => Redirect(routes.Projects.index).withSession("email" -> user._1) + ) + } + + /** + * Logout and clean the session. + */ + def logout = Action { + Redirect(routes.Application.login).withNewSession.flashing( + "success" -> "You've been logged out" + ) + } + + // -- Javascript routing + + def javascriptRoutes = Action { implicit request => + import routes.javascript._ + Ok( + Routes.javascriptRouter("jsRoutes")( + Projects.add, Projects.delete, Projects.rename, + Projects.addGroup, Projects.deleteGroup, Projects.renameGroup, + Projects.addUser, Projects.removeUser, Tasks.addFolder, + Tasks.renameFolder, Tasks.deleteFolder, Tasks.index, + Tasks.add, Tasks.update, Tasks.delete + ) + ).as("text/javascript") + } + +} + +/** + * Provide security features + */ +trait Secured { + + /** + * Retrieve the connected user email. + */ + private def username(request: RequestHeader) = request.session.get("email") + + /** + * Redirect to login if the user in not authorized. + */ + private def onUnauthorized(request: RequestHeader) = Results.Redirect(routes.Application.login) + + // -- + + /** + * Action for authenticated users. + */ + def IsAuthenticated(f: => String => Request[AnyContent] => Result) = Security.Authenticated(username, onUnauthorized) { user => + Action(request => f(user)(request)) + } + + /** + * Check if the connected user is a member of this project. + */ + def IsMemberOf(project: Long)(f: => String => Request[AnyContent] => Result) = IsAuthenticated { user => request => + if(Project.isMember(project, user)) { + f(user)(request) + } else { + Results.Forbidden + } + } + + /** + * Check if the connected user is a owner of this task. + */ + def IsOwnerOf(task: Long)(f: => String => Request[AnyContent] => Result) = IsAuthenticated { user => request => + if(Task.isOwner(task, user)) { + f(user)(request) + } else { + Results.Forbidden + } + } + +} + diff --git a/querulous/app/controllers/Projects.scala b/querulous/app/controllers/Projects.scala new file mode 100644 index 0000000..5ffe841 --- /dev/null +++ b/querulous/app/controllers/Projects.scala @@ -0,0 +1,123 @@ +package controllers + +import play.api._ +import play.api.mvc._ +import play.api.data._ +import play.api.data.Forms._ + +import anorm._ + +import models._ +import views._ + +/** + * Manage projects related operations. + */ +object Projects extends Controller with Secured { + + /** + * Display the dashboard. + */ + def index = IsAuthenticated { username => _ => + User.findByEmail(username).map { user => + Ok( + html.dashboard( + Project.findInvolving(username), + Task.findTodoInvolving(username), + user + ) + ) + }.getOrElse(Forbidden) + } + + // -- Projects + + /** + * Add a project. + */ + def add = IsAuthenticated { username => implicit request => + Form("group" -> nonEmptyText).bindFromRequest.fold( + errors => BadRequest, + folder => Ok( + views.html.projects.item( + Project.create( + Project(NotAssigned, folder, "New project"), + Seq(username) + ) + ) + ) + ) + } + + /** + * Delete a project. + */ + def delete(project: Long) = IsMemberOf(project) { username => _ => + Project.delete(project) + Ok + } + + /** + * Rename a project. + */ + def rename(project: Long) = IsMemberOf(project) { _ => implicit request => + Form("name" -> nonEmptyText).bindFromRequest.fold( + errors => BadRequest, + newName => { + Project.rename(project, newName) + Ok(newName) + } + ) + } + + // -- Project groups + + /** + * Add a new project group. + */ + def addGroup = IsAuthenticated { _ => _ => + Ok(html.projects.group("New group")) + } + + /** + * Delete a project group. + */ + def deleteGroup(folder: String) = IsAuthenticated { _ => _ => + Project.deleteInFolder(folder) + Ok + } + + /** + * Rename a project group. + */ + def renameGroup(folder: String) = IsAuthenticated { _ => implicit request => + Form("name" -> nonEmptyText).bindFromRequest.fold( + errors => BadRequest, + newName => { Project.renameFolder(folder, newName); Ok(newName) } + ) + } + + // -- Members + + /** + * Add a project member. + */ + def addUser(project: Long) = IsMemberOf(project) { _ => implicit request => + Form("user" -> nonEmptyText).bindFromRequest.fold( + errors => BadRequest, + user => { Project.addMember(project, user); Ok } + ) + } + + /** + * Remove a project member. + */ + def removeUser(project: Long) = IsMemberOf(project) { _ => implicit request => + Form("user" -> nonEmptyText).bindFromRequest.fold( + errors => BadRequest, + user => { Project.removeMember(project, user); Ok } + ) + } + +} + diff --git a/querulous/app/controllers/Tasks.scala b/querulous/app/controllers/Tasks.scala new file mode 100644 index 0000000..dc87a72 --- /dev/null +++ b/querulous/app/controllers/Tasks.scala @@ -0,0 +1,109 @@ +package controllers + +import play.api._ +import play.api.mvc._ +import play.api.data._ +import play.api.data.Forms._ + +import java.util.{Date} + +import anorm._ + +import models._ +import views._ + +/** + * Manage tasks related operations. + */ +object Tasks extends Controller with Secured { + + /** + * Display the tasks panel for this project. + */ + def index(project: Long) = IsMemberOf(project) { _ => implicit request => + Project.findById(project).map { p => + val tasks = Task.findByProject(project) + val team = Project.membersOf(project) + Ok(html.tasks.index(p, tasks, team)) + }.getOrElse(NotFound) + } + + val taskForm = Form( + tuple( + "title" -> nonEmptyText, + "dueDate" -> optional(date("MM/dd/yy")), + "assignedTo" -> optional(text) + ) + ) + + // -- Tasks + + /** + * Create a task in this project. + */ + def add(project: Long, folder: String) = IsMemberOf(project) { _ => implicit request => + taskForm.bindFromRequest.fold( + errors => BadRequest, + { + case (title, dueDate, assignedTo) => + val task = Task.create( + Task(NotAssigned, folder, project, title, false, dueDate, assignedTo) + ) + Ok(html.tasks.item(task)) + } + ) + } + + /** + * Update a task + */ + def update(task: Long) = IsOwnerOf(task) { _ => implicit request => + Form("done" -> boolean).bindFromRequest.fold( + errors => BadRequest, + isDone => { + Task.markAsDone(task, isDone) + Ok + } + ) + } + + /** + * Delete a task + */ + def delete(task: Long) = IsOwnerOf(task) { _ => implicit request => + Task.delete(task) + Ok + } + + // -- Task folders + + /** + * Add a new folder. + */ + def addFolder = Action { + Ok(html.tasks.folder("New folder")) + } + + /** + * Delete a full tasks folder. + */ + def deleteFolder(project: Long, folder: String) = IsMemberOf(project) { _ => implicit request => + Task.deleteInFolder(project, folder) + Ok + } + + /** + * Rename a tasks folder. + */ + def renameFolder(project: Long, folder: String) = IsMemberOf(project) { _ => implicit request => + Form("name" -> nonEmptyText).bindFromRequest.fold( + errors => BadRequest, + newName => { + Task.renameFolder(project, folder, newName) + Ok(newName) + } + ) + } + +} + diff --git a/querulous/app/models/DBStuff.scala b/querulous/app/models/DBStuff.scala new file mode 100644 index 0000000..95f816d --- /dev/null +++ b/querulous/app/models/DBStuff.scala @@ -0,0 +1,18 @@ +package models + +import com.twitter.querulous.evaluator.QueryEvaluator + +/** + * Author: chris + * Created: 7/26/12 + */ + +object DBStuff { + + val queryEvaluator = QueryEvaluator( + dbhost = "localhost", + dbname = "zentasks", + username = "chris", + password = "chris") + +} diff --git a/querulous/app/models/Project.scala b/querulous/app/models/Project.scala new file mode 100644 index 0000000..e39b927 --- /dev/null +++ b/querulous/app/models/Project.scala @@ -0,0 +1,137 @@ +package models + +import play.api.db._ +import play.api.Play.current + +import anorm._ +import anorm.SqlParser._ + +import java.sql.ResultSet +import DBStuff.queryEvaluator + +case class Project(id: Pk[Long], folder: String, name: String) + +object Project { + + + // -- Parsers + + /** + * Parse a Project from a ResultSet + */ + + val simpleQ = { row: ResultSet => + Project(Id(row.getLong("project.id")), row.getString("project.folder"), row.getString("project.name")) + } + + // -- Queries + + /** + * Retrieve a Project from id. + */ + def findById(id: Long): Option[Project] = { + queryEvaluator.selectOne("select * from project where id = ?", id)(simpleQ) + } + + /** + * Retrieve project for user + */ + def findInvolving(user: String): Seq[Project] = { + queryEvaluator.select( + """ + select * from project + join project_member on project.id = project_member.project_id + where project_member.user_email = ? + """, + user + )(simpleQ) + } + + /** + * Update a project. + */ + def rename(id: Long, newName: String) { + queryEvaluator.execute("update project set name = ? where id = ?", newName, id) + } + + /** + * Delete a project. + */ + def delete(id: Long) { + queryEvaluator.execute("delete from project where id = ?", id) + } + + /** + * Delete all project in a folder + */ + def deleteInFolder(folder: String) { + queryEvaluator.execute("delete from project where folder = ?", folder) + } + + /** + * Rename a folder + */ + def renameFolder(folder: String, newName: String) { + queryEvaluator.execute("update project set folder = ? where folder = ?", newName, folder) + } + + /** + * Retrieve project member + */ + def membersOf(project: Long): Seq[User] = { + queryEvaluator.select( + """ + select user.* from user + join project_member on project_member.user_email = user.email + where project_member.project_id = ? + """, + project + )(User.simpleQ) + } + + /** + * Add a member to the project team. + */ + def addMember(project: Long, user: String) { + queryEvaluator.insert("insert into project_member values(?, ?)", project, user) + } + + /** + * Remove a member from the project team. + */ + def removeMember(project: Long, user: String) { + queryEvaluator.execute("delete from project_member where project_id = ? and user_email = ?", project, user) + } + + /** + * Check if a user is a member of this project + */ + def isMember(project: Long, user: String): Boolean = { + queryEvaluator.select( + """ + select count(user.email) = 1 from user + join project_member on project_member.user_email = user.email + where project_member.project_id = ? and user.email = ? + """, + project, user + ) { row => + row.getBoolean(1) // columns are 1-indexed + }.head + } + + /** + * Create a Project. + */ + def create(project: Project, members: Seq[String]): Project = { + queryEvaluator.transaction { t => + val id = t.insert("insert into project (name, folder) values (?, ?)", project.name, project.folder) + + members.foreach { email => + t.insert("insert into project_member values (?, ?)", id, email) + } + + project.copy(id = Id(id)) + } + } + +} diff --git a/querulous/app/models/Task.scala b/querulous/app/models/Task.scala new file mode 100644 index 0000000..4549113 --- /dev/null +++ b/querulous/app/models/Task.scala @@ -0,0 +1,147 @@ +package models + +import java.util.{Date} + +import play.api.db._ +import play.api.Play.current + +import anorm._ +import anorm.SqlParser._ + +import java.sql.ResultSet +import DBStuff.queryEvaluator + +case class Task(id: Pk[Long], folder: String, project: Long, title: String, done: Boolean, dueDate: Option[Date], assignedTo: Option[String]) + +object Task { + + // -- Parsers + + /** + * Parse a Task from a ResultSet + */ + val simple = { + get[Pk[Long]]("task.id") ~ + get[String]("task.folder") ~ + get[Long]("task.project") ~ + get[String]("task.title") ~ + get[Boolean]("task.done") ~ + get[Option[Date]]("task.due_date") ~ + get[Option[String]]("task.assigned_to") map { + case id~folder~project~title~done~dueDate~assignedTo => Task( + id, folder, project, title, done, dueDate, assignedTo + ) + } + } + + val simpleQ = { row: ResultSet => + Task( + Id(row.getLong("task.id")), + row.getString("task.folder"), + row.getLong("task.project"), + row.getString("task.title"), + row.getBoolean("task.done"), + Option.apply(row.getDate("task.due_date")), + Option.apply(row.getString("task.assigned_to")) + ) + } + + // -- Queries + + /** + * Retrieve a Task from the id. + */ + def findById(id: Long): Option[Task] = { + queryEvaluator.selectOne("select * from task where id = ?", id)(simpleQ) + } + + /** + * Retrieve todo tasks for the user. + */ + def findTodoInvolving(user: String): Seq[(Task,Project)] = { + queryEvaluator.select( + """ + select * from task + join project_member on project_member.project_id = task.project + join project on project.id = project_member.project_id + where task.done = false and project_member.user_email = ? + """, + user + ){ row => + (simpleQ(row), Project.simpleQ(row)) + } + } + + /** + * Find tasks related to a project + */ + def findByProject(project: Long): Seq[Task] = { + queryEvaluator.select("select * from task where task.project = ?", project)(simpleQ) + } + + /** + * Delete a task + */ + def delete(id: Long) { + queryEvaluator.execute("delete from task where id = ?", id) + } + + /** + * Delete all task in a folder. + */ + def deleteInFolder(projectId: Long, folder: String) { + queryEvaluator.execute("delete from task where project = ? and folder = ?", projectId, folder) + } + + /** + * Mark a task as done or not + */ + def markAsDone(taskId: Long, done: Boolean) { + queryEvaluator.execute("update task set done = ? where id = ?", done, taskId) + } + + /** + * Rename a folder. + */ + def renameFolder(projectId: Long, folder: String, newName: String) { + queryEvaluator.execute("update task set folder = ? where folder = ? and project = ?", newName, folder, projectId) + } + + /** + * Check if a user is the owner of this task + */ + def isOwner(task: Long, user: String): Boolean = { + queryEvaluator.select( + """ + select count(task.id) = 1 from task + join project on task.project = project.id + join project_member on project_member.project_id = project.id + where project_member.user_email = ? and task.id = ? + """, + user, task + ){ row => + row.getBoolean(1) // columns are 1-indexed + }.head + } + + /** + * Create a Task. + */ + def create(task: Task): Task = { + val id = queryEvaluator.insert( + """ + insert into task (title, done, due_date, assigned_to, project, folder) + values (?, ?, ?, ?, ?, ?) + """, + task.title, + task.done, + task.dueDate, + task.assignedTo, + task.project, + task.folder + ) + + task.copy(id = Id(id)) + } + +} diff --git a/querulous/app/models/User.scala b/querulous/app/models/User.scala new file mode 100644 index 0000000..c25c85d --- /dev/null +++ b/querulous/app/models/User.scala @@ -0,0 +1,57 @@ +package models + +import play.api.db._ +import play.api.Play.current + +import anorm._ +import anorm.SqlParser._ + +import java.sql.ResultSet +import DBStuff.queryEvaluator + +case class User(email: String, name: String, password: String) + +object User { + + // -- Parsers + + /** + * Parse a User from a ResultSet + */ + + val simpleQ = { row: ResultSet => + User(row.getString("user.email"), row.getString("user.name"), row.getString("user.password")) + } + + // -- Queries + + /** + * Retrieve a User from email. + */ + def findByEmail(email: String): Option[User] = { + queryEvaluator.selectOne("select * from user where email = ?", email)(simpleQ) + } + + /** + * Retrieve all users. + */ + def findAll: Seq[User] = { + queryEvaluator.select("select * from user")(simpleQ) + } + + /** + * Authenticate a User. + */ + def authenticate(email: String, password: String): Option[User] = { + queryEvaluator.selectOne("select * from user where email = ? and password = ?", email, password)(simpleQ) + } + + /** + * Create a User. + */ + def create(user: User): User = { + queryEvaluator.insert("insert into user values (?, ?, ?)", user.email, user.name, user.password) + user + } + +} diff --git a/querulous/app/views/dashboard.scala.html b/querulous/app/views/dashboard.scala.html new file mode 100644 index 0000000..3b74d24 --- /dev/null +++ b/querulous/app/views/dashboard.scala.html @@ -0,0 +1,33 @@ +@(projects: Seq[Project], todoTasks: Seq[(Task,Project)], user: User) + +@main(projects, user){ + +
+
+

Dashboard

+

Tasks over all projects

+
+
+ +
+ @todoTasks.groupBy(_._2).map { + case (project, projectTasks) => { +
+
+

@project.name

+ Loading +
+ +
+ } + } +
+ +} + diff --git a/querulous/app/views/login.scala.html b/querulous/app/views/login.scala.html new file mode 100644 index 0000000..277c29d --- /dev/null +++ b/querulous/app/views/login.scala.html @@ -0,0 +1,51 @@ +@(form: Form[(String,String)])(implicit flash: Flash) + + + + Zentasks + + + + + +
+ +
+ + @helper.form(routes.Application.authenticate) { + +

Sign in

+ + @form.globalError.map { error => +

+ @error.message +

+ } + + @flash.get("success").map { message => +

+ @message +

+ } + +

+ +

+

+ +

+

+ +

+ + } + +

+ Try guillaume@@sample.com with secret as password. +

+ + + + + + diff --git a/querulous/app/views/main.scala.html b/querulous/app/views/main.scala.html new file mode 100644 index 0000000..53df511 --- /dev/null +++ b/querulous/app/views/main.scala.html @@ -0,0 +1,41 @@ +@(projects: Seq[Project], user: User)(body: Html) + + + + Zentasks + + + + + + + + + + +
+ +
+
@user.name (@user.email)
+
+ Logout +
+
+
+ +
+ @body +
+ + + diff --git a/querulous/app/views/projects/group.scala.html b/querulous/app/views/projects/group.scala.html new file mode 100644 index 0000000..1d39eb3 --- /dev/null +++ b/querulous/app/views/projects/group.scala.html @@ -0,0 +1,19 @@ +@(group: String, projects: Seq[Project] = Nil) + +
  • + +

    @group

    + Loading +
    +
    Options
    +
    + + +
    +
    + +
  • diff --git a/querulous/app/views/projects/item.scala.html b/querulous/app/views/projects/item.scala.html new file mode 100644 index 0000000..239d70a --- /dev/null +++ b/querulous/app/views/projects/item.scala.html @@ -0,0 +1,7 @@ +@(project: Project) + +
  • + @project.name + + Loading +
  • diff --git a/querulous/app/views/tasks/folder.scala.html b/querulous/app/views/tasks/folder.scala.html new file mode 100644 index 0000000..7e2d9a2 --- /dev/null +++ b/querulous/app/views/tasks/folder.scala.html @@ -0,0 +1,33 @@ +@(folder: String, tasks: Seq[Task] = Nil) + +
    +
    + +

    @folder

    + +
    +
    Options
    +
    + Remove complete tasks + Remove all tasks + Delete folder +
    +
    + Loading +
    + +
    + + + +
    + +
    + +
    +
    + diff --git a/querulous/app/views/tasks/index.scala.html b/querulous/app/views/tasks/index.scala.html new file mode 100644 index 0000000..e77b146 --- /dev/null +++ b/querulous/app/views/tasks/index.scala.html @@ -0,0 +1,42 @@ +@(project:Project, tasks: Seq[Task], team: Seq[User]) + +
    +
    +

    @project.folder

    +

    @project.name

    +
    +
    +
    Project's team
    +
    +
    +

    Team mates

    +
    + @team.map { user => +
    +
    @user.name (@user.email)
    +
    Action
    +
    + } +
    +

    Add a team mate

    +
    + @User.findAll.diff(team).map { user => +
    +
    @user.name (@user.email)
    +
    Action
    +
    + } +
    +
    +
    +
    +
    +
    + @tasks.groupBy(_.folder).map { + case (folder, tasks) => { + @views.html.tasks.folder(folder, tasks) + } + } + New folder +
    + diff --git a/querulous/app/views/tasks/item.scala.html b/querulous/app/views/tasks/item.scala.html new file mode 100644 index 0000000..1abb529 --- /dev/null +++ b/querulous/app/views/tasks/item.scala.html @@ -0,0 +1,24 @@ +@(task: Task, isEditable: Boolean = true) + +
  • + + @if(isEditable) { + + } + +

    @task.title

    + + @task.dueDate.map { date => + + } + + @task.assignedTo.map { user => + @user + } + + @if(isEditable) { + Delete task + Loading + } + +
  • diff --git a/querulous/conf/application.conf b/querulous/conf/application.conf new file mode 100644 index 0000000..1b83efd --- /dev/null +++ b/querulous/conf/application.conf @@ -0,0 +1,29 @@ +# Configuration + +application.name=Zentasks + +# Secret key +# ~~~~~ +# The secret key is used to secure cryptographics functions. +# If you deploy your application to several instances be sure to use the same key! +application.secret="E27D^[_9W" + +# Database configuration +# ~~~~~ +# You can declare as many datasources as you want. +# By convention, the default datasource is named `default` +db.default.driver=org.h2.Driver +db.default.url="jdbc:h2:mem:play" + +# Logger +# ~~~~~ +# You can also configure logback (http://logback.qos.ch/), by providing a logger.xml file in the conf directory . + +# Root logger: +logger=ERROR + +# Logger used by the framework: +logger.play=INFO + +# Logger provided to your application: +logger.application=DEBUG diff --git a/querulous/conf/routes b/querulous/conf/routes new file mode 100644 index 0000000..7d33254 --- /dev/null +++ b/querulous/conf/routes @@ -0,0 +1,41 @@ +# Routes +# This file defines all application routes (Higher priority routes first) +# ~~~~ + +# The home page +GET / controllers.Projects.index + +# Authentication +GET /login controllers.Application.login +POST /login controllers.Application.authenticate +GET /logout controllers.Application.logout + +# Projects +POST /projects controllers.Projects.add + +POST /projects/groups controllers.Projects.addGroup() +DELETE /projects/groups controllers.Projects.deleteGroup(group: String) +PUT /projects/groups controllers.Projects.renameGroup(group: String) + +DELETE /projects/:project controllers.Projects.delete(project: Long) +PUT /projects/:project controllers.Projects.rename(project: Long) + +POST /projects/:project/team controllers.Projects.addUser(project: Long) +DELETE /projects/:project/team controllers.Projects.removeUser(project: Long) + +# Tasks +GET /projects/:project/tasks controllers.Tasks.index(project: Long) +POST /projects/:project/tasks controllers.Tasks.add(project: Long, folder: String) +PUT /tasks/:task controllers.Tasks.update(task: Long) +DELETE /tasks/:task controllers.Tasks.delete(task: Long) + +POST /tasks/folder controllers.Tasks.addFolder +DELETE /projects/:project/tasks/folder controllers.Tasks.deleteFolder(project: Long, folder: String) +PUT /project/:project/tasks/folder controllers.Tasks.renameFolder(project: Long, folder: String) + +# Javascript routing +GET /assets/javascripts/routes controllers.Application.javascriptRoutes + +# Map static resources from the /public folder to the /public path +GET /assets/*file controllers.Assets.at(path="/public", file) + diff --git a/querulous/logs/application.log b/querulous/logs/application.log new file mode 100644 index 0000000..abf0b3b --- /dev/null +++ b/querulous/logs/application.log @@ -0,0 +1,9 @@ +2012-07-26 22:11:00,748 - [INFO] - from play in main +Listening for HTTP on port 9000... + +2012-07-26 22:11:38,468 - [INFO] - from play in play-akka.actor.default-dispatcher-3 +database [default] connected at jdbc:h2:mem:play + +2012-07-26 22:11:38,974 - [INFO] - from play in play-akka.actor.default-dispatcher-3 +Application started (Dev) + diff --git a/querulous/project/Build.scala b/querulous/project/Build.scala new file mode 100644 index 0000000..152ce2e --- /dev/null +++ b/querulous/project/Build.scala @@ -0,0 +1,23 @@ +import sbt._ +import Keys._ + +import PlayProject._ + +object ApplicationBuild extends Build { + + val appName = "zentask" + val appVersion = "1.0" + + val appDependencies = Seq( + //"com.twitter" % "querulous" % "2.7.6" + //"com.twitter" % "querulous_2.9.1" % "2.7.0" + "com.twitter" % "querulous-core_2.9.1" % "2.7.0", + "mysql" % "mysql-connector-java" % "5.1.21" + ) + + val main = PlayProject(appName, appVersion, appDependencies, mainLang = SCALA).settings( + resolvers += "Maven" at "http://maven.twttr.com/" + ) + +} + diff --git a/querulous/project/build.properties b/querulous/project/build.properties new file mode 100644 index 0000000..390c1af --- /dev/null +++ b/querulous/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.11.3 \ No newline at end of file diff --git a/querulous/project/plugins.sbt b/querulous/project/plugins.sbt new file mode 100644 index 0000000..6ebbeb1 --- /dev/null +++ b/querulous/project/plugins.sbt @@ -0,0 +1,10 @@ +// Comment to get more information during initialization +logLevel := Level.Warn + +// The Typesafe repository +resolvers += "Typesafe repository" at "http://repo.typesafe.com/typesafe/releases/" + +// Use the Play sbt plugin for Play projects +addSbtPlugin("play" % "sbt-plugin" % Option(System.getProperty("play.version")).getOrElse("2.0")) + + diff --git a/querulous/public/images/arrow-left.png b/querulous/public/images/arrow-left.png new file mode 100644 index 0000000..9458ca1 Binary files /dev/null and b/querulous/public/images/arrow-left.png differ diff --git a/querulous/public/images/breadcrumb-1.png b/querulous/public/images/breadcrumb-1.png new file mode 100644 index 0000000..2b48824 Binary files /dev/null and b/querulous/public/images/breadcrumb-1.png differ diff --git a/querulous/public/images/breadcrumb-2.png b/querulous/public/images/breadcrumb-2.png new file mode 100644 index 0000000..c77aa79 Binary files /dev/null and b/querulous/public/images/breadcrumb-2.png differ diff --git a/querulous/public/images/breadcrumb.png b/querulous/public/images/breadcrumb.png new file mode 100644 index 0000000..6033bd9 Binary files /dev/null and b/querulous/public/images/breadcrumb.png differ diff --git a/querulous/public/images/icons/addRemove.png b/querulous/public/images/icons/addRemove.png new file mode 100644 index 0000000..ad2bdb5 Binary files /dev/null and b/querulous/public/images/icons/addRemove.png differ diff --git a/querulous/public/images/icons/clock.png b/querulous/public/images/icons/clock.png new file mode 100644 index 0000000..287f369 Binary files /dev/null and b/querulous/public/images/icons/clock.png differ diff --git a/querulous/public/images/icons/delete.png b/querulous/public/images/icons/delete.png new file mode 100644 index 0000000..d3ba1fa Binary files /dev/null and b/querulous/public/images/icons/delete.png differ diff --git a/querulous/public/images/icons/drawer.folder.png b/querulous/public/images/icons/drawer.folder.png new file mode 100644 index 0000000..312c269 Binary files /dev/null and b/querulous/public/images/icons/drawer.folder.png differ diff --git a/querulous/public/images/icons/folder.png b/querulous/public/images/icons/folder.png new file mode 100644 index 0000000..c4b941e Binary files /dev/null and b/querulous/public/images/icons/folder.png differ diff --git a/querulous/public/images/icons/home.png b/querulous/public/images/icons/home.png new file mode 100644 index 0000000..646fd54 Binary files /dev/null and b/querulous/public/images/icons/home.png differ diff --git a/querulous/public/images/icons/options.png b/querulous/public/images/icons/options.png new file mode 100644 index 0000000..ff1b337 Binary files /dev/null and b/querulous/public/images/icons/options.png differ diff --git a/querulous/public/images/icons/user.png b/querulous/public/images/icons/user.png new file mode 100644 index 0000000..4493c0e Binary files /dev/null and b/querulous/public/images/icons/user.png differ diff --git a/querulous/public/images/icons/user2.png b/querulous/public/images/icons/user2.png new file mode 100644 index 0000000..f829ede Binary files /dev/null and b/querulous/public/images/icons/user2.png differ diff --git a/querulous/public/images/loading.gif b/querulous/public/images/loading.gif new file mode 100644 index 0000000..daa933c Binary files /dev/null and b/querulous/public/images/loading.gif differ diff --git a/querulous/public/images/pattern.png b/querulous/public/images/pattern.png new file mode 100644 index 0000000..a8eea3d Binary files /dev/null and b/querulous/public/images/pattern.png differ diff --git a/querulous/public/javascripts/backbone-min.js b/querulous/public/javascripts/backbone-min.js new file mode 100644 index 0000000..3f0d495 --- /dev/null +++ b/querulous/public/javascripts/backbone-min.js @@ -0,0 +1,33 @@ +// Backbone.js 0.5.3 +// (c) 2010 Jeremy Ashkenas, DocumentCloud Inc. +// Backbone may be freely distributed under the MIT license. +// For all details and documentation: +// http://documentcloud.github.com/backbone +(function(){var h=this,p=h.Backbone,e;e=typeof exports!=="undefined"?exports:h.Backbone={};e.VERSION="0.5.3";var f=h._;if(!f&&typeof require!=="undefined")f=require("underscore")._;var g=h.jQuery||h.Zepto;e.noConflict=function(){h.Backbone=p;return this};e.emulateHTTP=!1;e.emulateJSON=!1;e.Events={bind:function(a,b,c){var d=this._callbacks||(this._callbacks={});(d[a]||(d[a]=[])).push([b,c]);return this},unbind:function(a,b){var c;if(a){if(c=this._callbacks)if(b){c=c[a];if(!c)return this;for(var d= +0,e=c.length;d/g,">").replace(/"/g,""").replace(/'/g,"'").replace(/\//g,"/")},has:function(a){return this.attributes[a]!=null},set:function(a,b){b||(b={});if(!a)return this;if(a.attributes)a=a.attributes;var c=this.attributes,d=this._escapedAttributes;if(!b.silent&&this.validate&&!this._performValidation(a,b))return!1;if(this.idAttribute in a)this.id=a[this.idAttribute]; +var e=this._changing;this._changing=!0;for(var g in a){var h=a[g];if(!f.isEqual(c[g],h))c[g]=h,delete d[g],this._changed=!0,b.silent||this.trigger("change:"+g,this,h,b)}!e&&!b.silent&&this._changed&&this.change(b);this._changing=!1;return this},unset:function(a,b){if(!(a in this.attributes))return this;b||(b={});var c={};c[a]=void 0;if(!b.silent&&this.validate&&!this._performValidation(c,b))return!1;delete this.attributes[a];delete this._escapedAttributes[a];a==this.idAttribute&&delete this.id;this._changed= +!0;b.silent||(this.trigger("change:"+a,this,void 0,b),this.change(b));return this},clear:function(a){a||(a={});var b,c=this.attributes,d={};for(b in c)d[b]=void 0;if(!a.silent&&this.validate&&!this._performValidation(d,a))return!1;this.attributes={};this._escapedAttributes={};this._changed=!0;if(!a.silent){for(b in c)this.trigger("change:"+b,this,void 0,a);this.change(a)}return this},fetch:function(a){a||(a={});var b=this,c=a.success;a.success=function(d,e,f){if(!b.set(b.parse(d,f),a))return!1;c&& +c(b,d)};a.error=i(a.error,b,a);return(this.sync||e.sync).call(this,"read",this,a)},save:function(a,b){b||(b={});if(a&&!this.set(a,b))return!1;var c=this,d=b.success;b.success=function(a,e,f){if(!c.set(c.parse(a,f),b))return!1;d&&d(c,a,f)};b.error=i(b.error,c,b);var f=this.isNew()?"create":"update";return(this.sync||e.sync).call(this,f,this,b)},destroy:function(a){a||(a={});if(this.isNew())return this.trigger("destroy",this,this.collection,a);var b=this,c=a.success;a.success=function(d){b.trigger("destroy", +b,b.collection,a);c&&c(b,d)};a.error=i(a.error,b,a);return(this.sync||e.sync).call(this,"delete",this,a)},url:function(){var a=k(this.collection)||this.urlRoot||l();if(this.isNew())return a;return a+(a.charAt(a.length-1)=="/"?"":"/")+encodeURIComponent(this.id)},parse:function(a){return a},clone:function(){return new this.constructor(this)},isNew:function(){return this.id==null},change:function(a){this.trigger("change",this,a);this._previousAttributes=f.clone(this.attributes);this._changed=!1},hasChanged:function(a){if(a)return this._previousAttributes[a]!= +this.attributes[a];return this._changed},changedAttributes:function(a){a||(a=this.attributes);var b=this._previousAttributes,c=!1,d;for(d in a)f.isEqual(b[d],a[d])||(c=c||{},c[d]=a[d]);return c},previous:function(a){if(!a||!this._previousAttributes)return null;return this._previousAttributes[a]},previousAttributes:function(){return f.clone(this._previousAttributes)},_performValidation:function(a,b){var c=this.validate(a);if(c)return b.error?b.error(this,c,b):this.trigger("error",this,c,b),!1;return!0}}); +e.Collection=function(a,b){b||(b={});if(b.comparator)this.comparator=b.comparator;f.bindAll(this,"_onModelEvent","_removeReference");this._reset();a&&this.reset(a,{silent:!0});this.initialize.apply(this,arguments)};f.extend(e.Collection.prototype,e.Events,{model:e.Model,initialize:function(){},toJSON:function(){return this.map(function(a){return a.toJSON()})},add:function(a,b){if(f.isArray(a))for(var c=0,d=a.length;c').hide().appendTo("body")[0].contentWindow,this.navigate(a); +this._hasPushState?g(window).bind("popstate",this.checkUrl):"onhashchange"in window&&!b?g(window).bind("hashchange",this.checkUrl):setInterval(this.checkUrl,this.interval);this.fragment=a;m=!0;a=window.location;b=a.pathname==this.options.root;if(this._wantsPushState&&!this._hasPushState&&!b)return this.fragment=this.getFragment(null,!0),window.location.replace(this.options.root+"#"+this.fragment),!0;else if(this._wantsPushState&&this._hasPushState&&b&&a.hash)this.fragment=a.hash.replace(j,""),window.history.replaceState({}, +document.title,a.protocol+"//"+a.host+this.options.root+this.fragment);if(!this.options.silent)return this.loadUrl()},route:function(a,b){this.handlers.unshift({route:a,callback:b})},checkUrl:function(){var a=this.getFragment();a==this.fragment&&this.iframe&&(a=this.getFragment(this.iframe.location.hash));if(a==this.fragment||a==decodeURIComponent(this.fragment))return!1;this.iframe&&this.navigate(a);this.loadUrl()||this.loadUrl(window.location.hash)},loadUrl:function(a){var b=this.fragment=this.getFragment(a); +return f.any(this.handlers,function(a){if(a.route.test(b))return a.callback(b),!0})},navigate:function(a,b){var c=(a||"").replace(j,"");if(!(this.fragment==c||this.fragment==decodeURIComponent(c))){if(this._hasPushState){var d=window.location;c.indexOf(this.options.root)!=0&&(c=this.options.root+c);this.fragment=c;window.history.pushState({},document.title,d.protocol+"//"+d.host+c)}else if(window.location.hash=this.fragment=c,this.iframe&&c!=this.getFragment(this.iframe.location.hash))this.iframe.document.open().close(), +this.iframe.location.hash=c;b&&this.loadUrl(a)}}});e.View=function(a){this.cid=f.uniqueId("view");this._configure(a||{});this._ensureElement();this.delegateEvents();this.initialize.apply(this,arguments)};var u=/^(\S+)\s*(.*)$/,n=["model","collection","el","id","attributes","className","tagName"];f.extend(e.View.prototype,e.Events,{tagName:"div",$:function(a){return g(a,this.el)},initialize:function(){},render:function(){return this},remove:function(){g(this.el).remove();return this},make:function(a, +b,c){a=document.createElement(a);b&&g(a).attr(b);c&&g(a).html(c);return a},delegateEvents:function(a){if(a||(a=this.events))for(var b in f.isFunction(a)&&(a=a.call(this)),g(this.el).unbind(".delegateEvents"+this.cid),a){var c=this[a[b]];if(!c)throw Error('Event "'+a[b]+'" does not exist');var d=b.match(u),e=d[1];d=d[2];c=f.bind(c,this);e+=".delegateEvents"+this.cid;d===""?g(this.el).bind(e,c):g(this.el).delegate(d,e,c)}},_configure:function(a){this.options&&(a=f.extend({},this.options,a));for(var b= +0,c=n.length;b=e.computed&&(e={value:a,computed:b})});return e.value};b.min=function(a,c,d){if(!c&&b.isArray(a))return Math.min.apply(Math,a);if(!c&&b.isEmpty(a))return Infinity;var e={computed:Infinity};j(a,function(a,b,h){b=c?c.call(d,a,b,h):a;bd?1:0}),"value")};b.groupBy=function(a,b){var d={};j(a,function(a,f){var g=b(a,f);(d[g]||(d[g]=[])).push(a)});return d};b.sortedIndex=function(a,c,d){d||(d=b.identity);for(var e=0,f=a.length;e>1;d(a[g])=0})})}; +b.difference=function(a,c){return b.filter(a,function(a){return!b.include(c,a)})};b.zip=function(){for(var a=i.call(arguments),c=b.max(b.pluck(a,"length")),d=Array(c),e=0;e=0;d--)b=[a[d].apply(this,b)];return b[0]}};b.after=function(a,b){return function(){if(--a<1)return b.apply(this, +arguments)}};b.keys=H||function(a){if(a!==Object(a))throw new TypeError("Invalid object");var b=[],d;for(d in a)l.call(a,d)&&(b[b.length]=d);return b};b.values=function(a){return b.map(a,b.identity)};b.functions=b.methods=function(a){var c=[],d;for(d in a)b.isFunction(a[d])&&c.push(d);return c.sort()};b.extend=function(a){j(i.call(arguments,1),function(b){for(var d in b)b[d]!==void 0&&(a[d]=b[d])});return a};b.defaults=function(a){j(i.call(arguments,1),function(b){for(var d in b)a[d]==null&&(a[d]= +b[d])});return a};b.clone=function(a){return b.isArray(a)?a.slice():b.extend({},a)};b.tap=function(a,b){b(a);return a};b.isEqual=function(a,b){return q(a,b,[])};b.isEmpty=function(a){if(b.isArray(a)||b.isString(a))return a.length===0;for(var c in a)if(l.call(a,c))return false;return true};b.isElement=function(a){return!!(a&&a.nodeType==1)};b.isArray=o||function(a){return u.call(a)==="[object Array]"};b.isObject=function(a){return a===Object(a)};b.isArguments=function(a){return!(!a||!l.call(a,"callee"))}; +b.isFunction=function(a){return!(!a||!a.constructor||!a.call||!a.apply)};b.isString=function(a){return!!(a===""||a&&a.charCodeAt&&a.substr)};b.isNumber=function(a){return!!(a===0||a&&a.toExponential&&a.toFixed)};b.isNaN=function(a){return a!==a};b.isBoolean=function(a){return a===true||a===false||u.call(a)=="[object Boolean]"};b.isDate=function(a){return!(!a||!a.getTimezoneOffset||!a.setUTCFullYear)};b.isRegExp=function(a){return!(!a||!a.test||!a.exec||!(a.ignoreCase||a.ignoreCase===false))};b.isNull= +function(a){return a===null};b.isUndefined=function(a){return a===void 0};b.noConflict=function(){r._=F;return this};b.identity=function(a){return a};b.times=function(a,b,d){for(var e=0;e/g,">").replace(/"/g,""").replace(/'/g,"'").replace(/\//g,"/")};b.mixin=function(a){j(b.functions(a),function(c){I(c,b[c]=a[c])})};var J=0;b.uniqueId=function(a){var b= +J++;return a?a+b:b};b.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};b.template=function(a,c){var d=b.templateSettings,d="var __p=[],print=function(){__p.push.apply(__p,arguments);};with(obj||{}){__p.push('"+a.replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(d.escape,function(a,b){return"',_.escape("+b.replace(/\\'/g,"'")+"),'"}).replace(d.interpolate,function(a,b){return"',"+b.replace(/\\'/g,"'")+",'"}).replace(d.evaluate||null,function(a, +b){return"');"+b.replace(/\\'/g,"'").replace(/[\r\n\t]/g," ")+"__p.push('"}).replace(/\r/g,"\\r").replace(/\n/g,"\\n").replace(/\t/g,"\\t")+"');}return __p.join('');",d=new Function("obj",d);return c?d(c):d};var m=function(a){this._wrapped=a};b.prototype=m.prototype;var t=function(a,c){return c?b(a).chain():a},I=function(a,c){m.prototype[a]=function(){var a=i.call(arguments);G.call(a,this._wrapped);return t(c.apply(b,a),this._chain)}};b.mixin(b);j("pop,push,reverse,shift,sort,splice,unshift".split(","), +function(a){var b=k[a];m.prototype[a]=function(){b.apply(this._wrapped,arguments);return t(this._wrapped,this._chain)}});j(["concat","join","slice"],function(a){var b=k[a];m.prototype[a]=function(){return t(b.apply(this._wrapped,arguments),this._chain)}});m.prototype.chain=function(){this._chain=true;return this};m.prototype.value=function(){return this._wrapped}})(); diff --git a/querulous/test/IntegrationSpec.scala b/querulous/test/IntegrationSpec.scala new file mode 100644 index 0000000..2ffd48a --- /dev/null +++ b/querulous/test/IntegrationSpec.scala @@ -0,0 +1,37 @@ +package test + +import org.specs2.mutable._ + +import play.api.test._ +import play.api.test.Helpers._ + +class IntegrationSpec extends Specification { + + "Application" should { + + "work from within a browser" in { + running(TestServer(3333), HTMLUNIT) { browser => + browser.goTo("http://localhost:3333/") + browser.$("header a").first.getText must equalTo("Zentasks") + browser.$("#email").text("guillaume@sample.com") + browser.$("#password").text("secret111") + browser.$("#loginbutton").click() + browser.pageSource must contain("Invalid email or password") + + browser.$("#email").text("guillaume@sample.com") + browser.$("#password").text("secret") + browser.$("#loginbutton").click() + browser.$("dl.error").size must equalTo(0) + browser.pageSource must not contain("Sign in") + browser.pageSource must contain("guillaume@sample.com") + browser.pageSource must contain("Logout") + + val items = browser.$("li") + items.size must equalTo(15) + items.get(3).getText must contain("Website Delete") + } + } + + } + +}