diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..11879cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +.settings +.classpath +.project +*.iml +*.ipr +*.iws +dist/ +lib_managed/ +project/boot/ +project/plugins/project/ +target/ + +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +#*.off +*.old + +# eclipse conf file +.settings +.classpath +.project +.manager +.scala_dependencies + +# idea +.idea +*.iml + +# building +target +build +null +tmp* +temp* +dist +test-output +build.log + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +#SHITTY output not in target directory +build.log +.DS_Store +derby.log + +db diff --git a/anorm/README b/anorm/README new file mode 100644 index 0000000..deca6d4 --- /dev/null +++ b/anorm/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/anorm/app/Global.scala b/anorm/app/Global.scala new file mode 100644 index 0000000..e250445 --- /dev/null +++ b/anorm/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/anorm/app/assets/javascripts/main.coffee b/anorm/app/assets/javascripts/main.coffee new file mode 100644 index 0000000..f096d97 --- /dev/null +++ b/anorm/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/anorm/app/assets/stylesheets/apps/_tasks.less b/anorm/app/assets/stylesheets/apps/_tasks.less new file mode 100644 index 0000000..fdd28ab --- /dev/null +++ b/anorm/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/anorm/app/assets/stylesheets/libs/_mate.less b/anorm/app/assets/stylesheets/libs/_mate.less new file mode 100644 index 0000000..cf06688 --- /dev/null +++ b/anorm/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/anorm/app/assets/stylesheets/libs/_reset.less b/anorm/app/assets/stylesheets/libs/_reset.less new file mode 100644 index 0000000..dc347d5 --- /dev/null +++ b/anorm/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/anorm/app/assets/stylesheets/libs/_theme.less b/anorm/app/assets/stylesheets/libs/_theme.less new file mode 100644 index 0000000..97f957c --- /dev/null +++ b/anorm/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/anorm/app/assets/stylesheets/login.less b/anorm/app/assets/stylesheets/login.less new file mode 100644 index 0000000..ae5b2c7 --- /dev/null +++ b/anorm/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/anorm/app/assets/stylesheets/main.less b/anorm/app/assets/stylesheets/main.less new file mode 100644 index 0000000..057ae1e --- /dev/null +++ b/anorm/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/anorm/app/assets/stylesheets/main/_breadcrumb.less b/anorm/app/assets/stylesheets/main/_breadcrumb.less new file mode 100644 index 0000000..c8bde04 --- /dev/null +++ b/anorm/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/anorm/app/assets/stylesheets/main/_drawer.less b/anorm/app/assets/stylesheets/main/_drawer.less new file mode 100644 index 0000000..4cc46c7 --- /dev/null +++ b/anorm/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/anorm/app/assets/stylesheets/main/_header.less b/anorm/app/assets/stylesheets/main/_header.less new file mode 100644 index 0000000..dbaee12 --- /dev/null +++ b/anorm/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/anorm/app/assets/stylesheets/main/_layout.less b/anorm/app/assets/stylesheets/main/_layout.less new file mode 100644 index 0000000..046c70c --- /dev/null +++ b/anorm/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/anorm/app/assets/stylesheets/main/_widgets.less b/anorm/app/assets/stylesheets/main/_widgets.less new file mode 100644 index 0000000..ada18d0 --- /dev/null +++ b/anorm/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/anorm/app/controllers/Application.scala b/anorm/app/controllers/Application.scala new file mode 100644 index 0000000..856b3b4 --- /dev/null +++ b/anorm/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/anorm/app/controllers/Projects.scala b/anorm/app/controllers/Projects.scala new file mode 100644 index 0000000..5ffe841 --- /dev/null +++ b/anorm/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/anorm/app/controllers/Tasks.scala b/anorm/app/controllers/Tasks.scala new file mode 100644 index 0000000..dc87a72 --- /dev/null +++ b/anorm/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/anorm/app/models/Project.scala b/anorm/app/models/Project.scala new file mode 100644 index 0000000..d48dfdd --- /dev/null +++ b/anorm/app/models/Project.scala @@ -0,0 +1,193 @@ +package models + +import play.api.db._ +import play.api.Play.current + +import anorm._ +import anorm.SqlParser._ + +case class Project(id: Pk[Long], folder: String, name: String) + +object Project { + + // -- Parsers + + /** + * Parse a Project from a ResultSet + */ + val simple = { + get[Pk[Long]]("project.id") ~ + get[String]("project.folder") ~ + get[String]("project.name") map { + case id~folder~name => Project(id, folder, name) + } + } + + // -- Queries + + /** + * Retrieve a Project from id. + */ + def findById(id: Long): Option[Project] = { + DB.withConnection { implicit connection => + SQL("select * from project where id = {id}").on( + 'id -> id + ).as(Project.simple.singleOpt) + } + } + + /** + * Retrieve project for user + */ + def findInvolving(user: String): Seq[Project] = { + DB.withConnection { implicit connection => + SQL( + """ + select * from project + join project_member on project.id = project_member.project_id + where project_member.user_email = {email} + """ + ).on( + 'email -> user + ).as(Project.simple *) + } + } + + /** + * Update a project. + */ + def rename(id: Long, newName: String) { + DB.withConnection { implicit connection => + SQL("update project set name = {name} where id = {id}").on( + 'id -> id, 'name -> newName + ).executeUpdate() + } + } + + /** + * Delete a project. + */ + def delete(id: Long) { + DB.withConnection { implicit connection => + SQL("delete from project where id = {id}").on( + 'id -> id + ).executeUpdate() + } + } + + /** + * Delete all project in a folder + */ + def deleteInFolder(folder: String) { + DB.withConnection { implicit connection => + SQL("delete from project where folder = {folder}").on( + 'folder -> folder + ).executeUpdate() + } + } + + /** + * Rename a folder + */ + def renameFolder(folder: String, newName: String) { + DB.withConnection { implicit connection => + SQL("update project set folder = {newName} where folder = {name}").on( + 'name -> folder, 'newName -> newName + ).executeUpdate() + } + } + + /** + * Retrieve project member + */ + def membersOf(project: Long): Seq[User] = { + DB.withConnection { implicit connection => + SQL( + """ + select user.* from user + join project_member on project_member.user_email = user.email + where project_member.project_id = {project} + """ + ).on( + 'project -> project + ).as(User.simple *) + } + } + + /** + * Add a member to the project team. + */ + def addMember(project: Long, user: String) { + DB.withConnection { implicit connection => + SQL("insert into project_member values({project}, {user})").on( + 'project -> project, + 'user -> user + ).executeUpdate() + } + } + + /** + * Remove a member from the project team. + */ + def removeMember(project: Long, user: String) { + DB.withConnection { implicit connection => + SQL("delete from project_member where project_id = {project} and user_email = {user}").on( + 'project -> project, + 'user -> user + ).executeUpdate() + } + } + + /** + * Check if a user is a member of this project + */ + def isMember(project: Long, user: String): Boolean = { + DB.withConnection { implicit connection => + SQL( + """ + select count(user.email) = 1 from user + join project_member on project_member.user_email = user.email + where project_member.project_id = {project} and user.email = {email} + """ + ).on( + 'project -> project, + 'email -> user + ).as(scalar[Boolean].single) + } + } + + /** + * Create a Project. + */ + def create(project: Project, members: Seq[String]): Project = { + DB.withTransaction { implicit connection => + + // Get the project id + val id: Long = project.id.getOrElse { + SQL("select next value for project_seq").as(scalar[Long].single) + } + + // Insert the project + SQL( + """ + insert into project values ( + {id}, {name}, {folder} + ) + """ + ).on( + 'id -> id, + 'name -> project.name, + 'folder -> project.folder + ).executeUpdate() + + // Add members + members.foreach { email => + SQL("insert into project_member values ({id}, {email})").on('id -> id, 'email -> email).executeUpdate() + } + + project.copy(id = Id(id)) + + } + } + +} diff --git a/anorm/app/models/Task.scala b/anorm/app/models/Task.scala new file mode 100644 index 0000000..75f4381 --- /dev/null +++ b/anorm/app/models/Task.scala @@ -0,0 +1,179 @@ +package models + +import java.util.{Date} + +import play.api.db._ +import play.api.Play.current + +import anorm._ +import anorm.SqlParser._ + +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 + ) + } + } + + // -- Queries + + /** + * Retrieve a Task from the id. + */ + def findById(id: Long): Option[Task] = { + DB.withConnection { implicit connection => + SQL("select * from task where id = {id}").on( + 'id -> id + ).as(Task.simple.singleOpt) + } + } + + /** + * Retrieve todo tasks for the user. + */ + def findTodoInvolving(user: String): Seq[(Task,Project)] = { + DB.withConnection { implicit connection => + SQL( + """ + 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 = {email} + """ + ).on( + 'email -> user + ).as(Task.simple ~ Project.simple map { + case task~project => task -> project + } *) + } + } + + /** + * Find tasks related to a project + */ + def findByProject(project: Long): Seq[Task] = { + DB.withConnection { implicit connection => + SQL( + """ + select * from task + where task.project = {project} + """ + ).on( + 'project -> project + ).as(Task.simple *) + } + } + + /** + * Delete a task + */ + def delete(id: Long) { + DB.withConnection { implicit connection => + SQL("delete from task where id = {id}").on( + 'id -> id + ).executeUpdate() + } + } + + /** + * Delete all task in a folder. + */ + def deleteInFolder(projectId: Long, folder: String) { + DB.withConnection { implicit connection => + SQL("delete from task where project = {project} and folder = {folder}").on( + 'project -> projectId, 'folder -> folder + ).executeUpdate() + } + } + + /** + * Mark a task as done or not + */ + def markAsDone(taskId: Long, done: Boolean) { + DB.withConnection { implicit connection => + SQL("update task set done = {done} where id = {id}").on( + 'id -> taskId, + 'done -> done + ).executeUpdate() + } + } + + /** + * Rename a folder. + */ + def renameFolder(projectId: Long, folder: String, newName: String) { + DB.withConnection { implicit connection => + SQL("update task set folder = {newName} where folder = {name} and project = {project}").on( + 'project -> projectId, 'name -> folder, 'newName -> newName + ).executeUpdate() + } + } + + /** + * Check if a user is the owner of this task + */ + def isOwner(task: Long, user: String): Boolean = { + DB.withConnection { implicit connection => + SQL( + """ + 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 = {email} and task.id = {task} + """ + ).on( + 'task -> task, + 'email -> user + ).as(scalar[Boolean].single) + } + } + + /** + * Create a Task. + */ + def create(task: Task): Task = { + DB.withConnection { implicit connection => + + // Get the task id + val id: Long = task.id.getOrElse { + SQL("select next value for task_seq").as(scalar[Long].single) + } + + SQL( + """ + insert into task values ( + {id}, {title}, {done}, {dueDate}, {assignedTo}, {project}, {folder} + ) + """ + ).on( + 'id -> id, + 'folder -> task.folder, + 'project -> task.project, + 'title -> task.title, + 'done -> task.done, + 'dueDate -> task.dueDate, + 'assignedTo -> task.assignedTo + ).executeUpdate() + + task.copy(id = Id(id)) + + } + } + +} diff --git a/anorm/app/models/User.scala b/anorm/app/models/User.scala new file mode 100644 index 0000000..7c4bf05 --- /dev/null +++ b/anorm/app/models/User.scala @@ -0,0 +1,87 @@ +package models + +import play.api.db._ +import play.api.Play.current + +import anorm._ +import anorm.SqlParser._ + +case class User(email: String, name: String, password: String) + +object User { + + // -- Parsers + + /** + * Parse a User from a ResultSet + */ + val simple = { + get[String]("user.email") ~ + get[String]("user.name") ~ + get[String]("user.password") map { + case email~name~password => User(email, name, password) + } + } + + // -- Queries + + /** + * Retrieve a User from email. + */ + def findByEmail(email: String): Option[User] = { + DB.withConnection { implicit connection => + SQL("select * from user where email = {email}").on( + 'email -> email + ).as(User.simple.singleOpt) + } + } + + /** + * Retrieve all users. + */ + def findAll: Seq[User] = { + DB.withConnection { implicit connection => + SQL("select * from user").as(User.simple *) + } + } + + /** + * Authenticate a User. + */ + def authenticate(email: String, password: String): Option[User] = { + DB.withConnection { implicit connection => + SQL( + """ + select * from user where + email = {email} and password = {password} + """ + ).on( + 'email -> email, + 'password -> password + ).as(User.simple.singleOpt) + } + } + + /** + * Create a User. + */ + def create(user: User): User = { + DB.withConnection { implicit connection => + SQL( + """ + insert into user values ( + {email}, {name}, {password} + ) + """ + ).on( + 'email -> user.email, + 'name -> user.name, + 'password -> user.password + ).executeUpdate() + + user + + } + } + +} diff --git a/anorm/app/views/dashboard.scala.html b/anorm/app/views/dashboard.scala.html new file mode 100644 index 0000000..3b74d24 --- /dev/null +++ b/anorm/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/anorm/app/views/login.scala.html b/anorm/app/views/login.scala.html new file mode 100644 index 0000000..277c29d --- /dev/null +++ b/anorm/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/anorm/app/views/main.scala.html b/anorm/app/views/main.scala.html new file mode 100644 index 0000000..53df511 --- /dev/null +++ b/anorm/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/anorm/app/views/projects/group.scala.html b/anorm/app/views/projects/group.scala.html new file mode 100644 index 0000000..1d39eb3 --- /dev/null +++ b/anorm/app/views/projects/group.scala.html @@ -0,0 +1,19 @@ +@(group: String, projects: Seq[Project] = Nil) + +
  • + +

    @group

    + Loading +
    +
    Options
    +
    + + +
    +
    + +
  • diff --git a/anorm/app/views/projects/item.scala.html b/anorm/app/views/projects/item.scala.html new file mode 100644 index 0000000..239d70a --- /dev/null +++ b/anorm/app/views/projects/item.scala.html @@ -0,0 +1,7 @@ +@(project: Project) + +
  • + @project.name + + Loading +
  • diff --git a/anorm/app/views/tasks/folder.scala.html b/anorm/app/views/tasks/folder.scala.html new file mode 100644 index 0000000..7e2d9a2 --- /dev/null +++ b/anorm/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/anorm/app/views/tasks/index.scala.html b/anorm/app/views/tasks/index.scala.html new file mode 100644 index 0000000..e77b146 --- /dev/null +++ b/anorm/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/anorm/app/views/tasks/item.scala.html b/anorm/app/views/tasks/item.scala.html new file mode 100644 index 0000000..1abb529 --- /dev/null +++ b/anorm/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/anorm/conf/application.conf b/anorm/conf/application.conf new file mode 100644 index 0000000..1b83efd --- /dev/null +++ b/anorm/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/anorm/conf/evolutions/default/1.sql b/anorm/conf/evolutions/default/1.sql new file mode 100644 index 0000000..5399047 --- /dev/null +++ b/anorm/conf/evolutions/default/1.sql @@ -0,0 +1,47 @@ +# --- First database schema + +# --- !Ups + +create table user ( + email varchar(255) not null primary key, + name varchar(255) not null, + password varchar(255) not null +); + +create table project ( + id bigint not null primary key, + name varchar(255) not null, + folder varchar(255) not null +); + +create sequence project_seq start with 1000; + +create table project_member ( + project_id bigint not null, + user_email varchar(255) not null, + foreign key(project_id) references project(id) on delete cascade, + foreign key(user_email) references user(email) on delete cascade +); + +create table task ( + id bigint not null primary key, + title varchar(255) not null, + done boolean, + due_date timestamp, + assigned_to varchar(255), + project bigint not null, + folder varchar(255), + foreign key(assigned_to) references user(email) on delete set null, + foreign key(project) references project(id) on delete cascade +); + +create sequence task_seq start with 1000; + +# --- !Downs + +drop table if exists task; +drop sequence if exists task_seq; +drop table if exists project_member; +drop table if exists project; +drop sequence if exists project_seq; +drop table if exists user; diff --git a/anorm/conf/routes b/anorm/conf/routes new file mode 100644 index 0000000..7d33254 --- /dev/null +++ b/anorm/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/anorm/project/Build.scala b/anorm/project/Build.scala new file mode 100644 index 0000000..36521cc --- /dev/null +++ b/anorm/project/Build.scala @@ -0,0 +1,14 @@ +import sbt._ +import Keys._ + +import PlayProject._ + +object ApplicationBuild extends Build { + + val appName = "zentask" + val appVersion = "1.0" + + val main = PlayProject(appName, appVersion, mainLang = SCALA) + +} + diff --git a/anorm/project/build.properties b/anorm/project/build.properties new file mode 100644 index 0000000..390c1af --- /dev/null +++ b/anorm/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.11.3 \ No newline at end of file diff --git a/anorm/project/plugins.sbt b/anorm/project/plugins.sbt new file mode 100644 index 0000000..fae77de --- /dev/null +++ b/anorm/project/plugins.sbt @@ -0,0 +1,8 @@ +// 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")) \ No newline at end of file diff --git a/anorm/public/images/arrow-left.png b/anorm/public/images/arrow-left.png new file mode 100644 index 0000000..9458ca1 Binary files /dev/null and b/anorm/public/images/arrow-left.png differ diff --git a/anorm/public/images/breadcrumb-1.png b/anorm/public/images/breadcrumb-1.png new file mode 100644 index 0000000..2b48824 Binary files /dev/null and b/anorm/public/images/breadcrumb-1.png differ diff --git a/anorm/public/images/breadcrumb-2.png b/anorm/public/images/breadcrumb-2.png new file mode 100644 index 0000000..c77aa79 Binary files /dev/null and b/anorm/public/images/breadcrumb-2.png differ diff --git a/anorm/public/images/breadcrumb.png b/anorm/public/images/breadcrumb.png new file mode 100644 index 0000000..6033bd9 Binary files /dev/null and b/anorm/public/images/breadcrumb.png differ diff --git a/anorm/public/images/icons/addRemove.png b/anorm/public/images/icons/addRemove.png new file mode 100644 index 0000000..ad2bdb5 Binary files /dev/null and b/anorm/public/images/icons/addRemove.png differ diff --git a/anorm/public/images/icons/clock.png b/anorm/public/images/icons/clock.png new file mode 100644 index 0000000..287f369 Binary files /dev/null and b/anorm/public/images/icons/clock.png differ diff --git a/anorm/public/images/icons/delete.png b/anorm/public/images/icons/delete.png new file mode 100644 index 0000000..d3ba1fa Binary files /dev/null and b/anorm/public/images/icons/delete.png differ diff --git a/anorm/public/images/icons/drawer.folder.png b/anorm/public/images/icons/drawer.folder.png new file mode 100644 index 0000000..312c269 Binary files /dev/null and b/anorm/public/images/icons/drawer.folder.png differ diff --git a/anorm/public/images/icons/folder.png b/anorm/public/images/icons/folder.png new file mode 100644 index 0000000..c4b941e Binary files /dev/null and b/anorm/public/images/icons/folder.png differ diff --git a/anorm/public/images/icons/home.png b/anorm/public/images/icons/home.png new file mode 100644 index 0000000..646fd54 Binary files /dev/null and b/anorm/public/images/icons/home.png differ diff --git a/anorm/public/images/icons/options.png b/anorm/public/images/icons/options.png new file mode 100644 index 0000000..ff1b337 Binary files /dev/null and b/anorm/public/images/icons/options.png differ diff --git a/anorm/public/images/icons/user.png b/anorm/public/images/icons/user.png new file mode 100644 index 0000000..4493c0e Binary files /dev/null and b/anorm/public/images/icons/user.png differ diff --git a/anorm/public/images/icons/user2.png b/anorm/public/images/icons/user2.png new file mode 100644 index 0000000..f829ede Binary files /dev/null and b/anorm/public/images/icons/user2.png differ diff --git a/anorm/public/images/loading.gif b/anorm/public/images/loading.gif new file mode 100644 index 0000000..daa933c Binary files /dev/null and b/anorm/public/images/loading.gif differ diff --git a/anorm/public/images/pattern.png b/anorm/public/images/pattern.png new file mode 100644 index 0000000..a8eea3d Binary files /dev/null and b/anorm/public/images/pattern.png differ diff --git a/anorm/public/javascripts/backbone-min.js b/anorm/public/javascripts/backbone-min.js new file mode 100644 index 0000000..3f0d495 --- /dev/null +++ b/anorm/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/anorm/test/ApplicationSpec.scala b/anorm/test/ApplicationSpec.scala new file mode 100644 index 0000000..d248091 --- /dev/null +++ b/anorm/test/ApplicationSpec.scala @@ -0,0 +1,28 @@ +package test + +import org.specs2.mutable._ + +import play.api.test._ +import play.api.test.Helpers._ + +class ApplicationSpec extends Specification { + + + "Application" should { + + + "go to login page without credentials" in { + running(FakeApplication(additionalConfiguration = inMemoryDatabase())) { + val result = routeAndCall( FakeRequest( GET, "/")).get + status(result) must equalTo(303) + } + } + "list the secured product page with credentials" in { + running(FakeApplication(additionalConfiguration = inMemoryDatabase())) { + val result = routeAndCall( FakeRequest( GET, "/").withSession("email"->"guillaume@sample.com")).get + status(result) must equalTo(200) + } + } + + } +} diff --git a/anorm/test/IntegrationSpec.scala b/anorm/test/IntegrationSpec.scala new file mode 100644 index 0000000..2ffd48a --- /dev/null +++ b/anorm/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") + } + } + + } + +} diff --git a/scalikejdbc/README b/scalikejdbc/README new file mode 100644 index 0000000..deca6d4 --- /dev/null +++ b/scalikejdbc/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/scalikejdbc/app/Global.scala b/scalikejdbc/app/Global.scala new file mode 100644 index 0000000..450c7d4 --- /dev/null +++ b/scalikejdbc/app/Global.scala @@ -0,0 +1,118 @@ +import play.api._ + +import models._ + +object Global extends GlobalSettings { + + override def onStart(app: Application) { + InitialData.createTables() + 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 createTables() = { + + import scalikejdbc._ + val ddl = """ +drop table user if exists; +create table user ( + email varchar(255) not null primary key, + name varchar(255) not null, + password varchar(255) not null +); + +drop table project if exists; +create table project ( + id bigint not null primary key, + name varchar(255) not null, + folder varchar(255) not null +); + +drop sequence project_seq if exists; +create sequence project_seq start with 1000; + +drop table project_member if exists; +create table project_member ( + project_id bigint not null, + user_email varchar(255) not null, + foreign key(project_id) references project(id) on delete cascade, + foreign key(user_email) references user(email) on delete cascade +); + +drop table task if exists; +create table task ( + id bigint not null primary key, + title varchar(255) not null, + done boolean, + due_date timestamp, + assigned_to varchar(255), + project bigint not null, + folder varchar(255), + foreign key(assigned_to) references user(email) on delete set null, + foreign key(project) references project(id) on delete cascade +); + +drop sequence task_seq if exists; +create sequence task_seq start with 1000; +""" + + DB autoCommit { implicit session => + try { + SQL("select * from user").map(rs => rs).list.apply() + } catch { case e => + SQL(ddl).execute.apply() + } + } + + } + + 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) + + val projects = Seq( + NewProject("Play framework", "Play 2.0") -> Seq("guillaume@sample.com", "maxime@sample.com", "sadek@sample.com", "erwan@sample.com"), + NewProject("Play framework", "Play 1.2.4") -> Seq("guillaume@sample.com", "erwan@sample.com"), + NewProject("Play framework", "Website") -> Seq("guillaume@sample.com", "maxime@sample.com"), + NewProject("Zenexity", "Secret project") -> Seq("guillaume@sample.com", "maxime@sample.com", "sadek@sample.com", "erwan@sample.com"), + NewProject("Zenexity", "Playmate") -> Seq("maxime@sample.com"), + NewProject("Personal", "Things to do") -> Seq("guillaume@sample.com"), + NewProject("Zenexity", "Play samples") -> Seq("guillaume@sample.com", "maxime@sample.com"), + NewProject("Personal", "Private") -> Seq("maxime@sample.com"), + NewProject("Personal", "Private") -> Seq("guillaume@sample.com"), + NewProject("Personal", "Private") -> Seq("erwan@sample.com"), + NewProject("Personal", "Private") -> Seq("sadek@sample.com") + ).map { + case (project,members) => Project.create(project, members) + } + + Seq( + NewTask("Todo", projects(0).id, "Fix the documentation", false, None, Some("guillaume@sample.com")), + NewTask("Urgent", projects(0).id, "Prepare the beta release", false, Some(date("2011-11-15")), None), + NewTask("Todo", projects(8).id, "Buy some milk", false, None, None), + NewTask("Todo", projects(1).id, "Check 1.2.4-RC2", false, Some(date("2011-11-18")), Some("guillaume@sample.com")), + NewTask("Todo", projects(6).id, "Finish zentask integration", true, Some(date("2011-11-15")), Some("maxime@sample.com")), + NewTask( "Todo", projects(3).id, "Release the secret project", false, Some(date("2012-01-01")), Some("sadek@sample.com")) + ).foreach(Task.create) + + } + + } + +} diff --git a/scalikejdbc/app/assets/javascripts/main.coffee b/scalikejdbc/app/assets/javascripts/main.coffee new file mode 100644 index 0000000..4ae1eaf --- /dev/null +++ b/scalikejdbc/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/scalikejdbc/app/assets/stylesheets/apps/_tasks.less b/scalikejdbc/app/assets/stylesheets/apps/_tasks.less new file mode 100644 index 0000000..fdd28ab --- /dev/null +++ b/scalikejdbc/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/scalikejdbc/app/assets/stylesheets/libs/_mate.less b/scalikejdbc/app/assets/stylesheets/libs/_mate.less new file mode 100644 index 0000000..cf06688 --- /dev/null +++ b/scalikejdbc/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/scalikejdbc/app/assets/stylesheets/libs/_reset.less b/scalikejdbc/app/assets/stylesheets/libs/_reset.less new file mode 100644 index 0000000..dc347d5 --- /dev/null +++ b/scalikejdbc/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/scalikejdbc/app/assets/stylesheets/libs/_theme.less b/scalikejdbc/app/assets/stylesheets/libs/_theme.less new file mode 100644 index 0000000..97f957c --- /dev/null +++ b/scalikejdbc/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/scalikejdbc/app/assets/stylesheets/login.less b/scalikejdbc/app/assets/stylesheets/login.less new file mode 100644 index 0000000..ae5b2c7 --- /dev/null +++ b/scalikejdbc/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/scalikejdbc/app/assets/stylesheets/main.less b/scalikejdbc/app/assets/stylesheets/main.less new file mode 100644 index 0000000..057ae1e --- /dev/null +++ b/scalikejdbc/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/scalikejdbc/app/assets/stylesheets/main/_breadcrumb.less b/scalikejdbc/app/assets/stylesheets/main/_breadcrumb.less new file mode 100644 index 0000000..c8bde04 --- /dev/null +++ b/scalikejdbc/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/scalikejdbc/app/assets/stylesheets/main/_drawer.less b/scalikejdbc/app/assets/stylesheets/main/_drawer.less new file mode 100644 index 0000000..4cc46c7 --- /dev/null +++ b/scalikejdbc/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/scalikejdbc/app/assets/stylesheets/main/_header.less b/scalikejdbc/app/assets/stylesheets/main/_header.less new file mode 100644 index 0000000..dbaee12 --- /dev/null +++ b/scalikejdbc/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/scalikejdbc/app/assets/stylesheets/main/_layout.less b/scalikejdbc/app/assets/stylesheets/main/_layout.less new file mode 100644 index 0000000..046c70c --- /dev/null +++ b/scalikejdbc/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/scalikejdbc/app/assets/stylesheets/main/_widgets.less b/scalikejdbc/app/assets/stylesheets/main/_widgets.less new file mode 100644 index 0000000..ada18d0 --- /dev/null +++ b/scalikejdbc/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/scalikejdbc/app/controllers/Application.scala b/scalikejdbc/app/controllers/Application.scala new file mode 100644 index 0000000..856b3b4 --- /dev/null +++ b/scalikejdbc/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/scalikejdbc/app/controllers/Projects.scala b/scalikejdbc/app/controllers/Projects.scala new file mode 100644 index 0000000..a1349f9 --- /dev/null +++ b/scalikejdbc/app/controllers/Projects.scala @@ -0,0 +1,121 @@ +package controllers + +import play.api._ +import play.api.mvc._ +import play.api.data._ +import play.api.data.Forms._ + +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( + NewProject(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/scalikejdbc/app/controllers/Tasks.scala b/scalikejdbc/app/controllers/Tasks.scala new file mode 100644 index 0000000..26e9002 --- /dev/null +++ b/scalikejdbc/app/controllers/Tasks.scala @@ -0,0 +1,106 @@ +package controllers + +import play.api._ +import play.api.mvc._ +import play.api.data._ +import play.api.data.Forms._ + +import java.util.{Date} +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( + NewTask(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/scalikejdbc/app/models/Project.scala b/scalikejdbc/app/models/Project.scala new file mode 100644 index 0000000..1f90d6e --- /dev/null +++ b/scalikejdbc/app/models/Project.scala @@ -0,0 +1,141 @@ +package models + +import scalikejdbc._ + +case class NewProject( + folder: String, + name: String +) + +case class Project( + id: Long, + folder: String, + name: String +) + +object Project { + + // -- Queries + private val simple = (rs: WrappedResultSet) => Project( + rs.long("id"), + rs.string("folder"), + rs.string("name") + ) + + /** + * Retrieve a Project from id. + */ + def findById(id: Long)(implicit session: DBSession = AutoSession): Option[Project] = { + SQL("select * from project where id = {id}") + .bindByName('id -> id).map(simple).single.apply() + } + + /** + * Retrieve project for user + */ + def findInvolving(user: String)(implicit session: DBSession = AutoSession): Seq[Project] = { + SQL( + """ + select * from project + join project_member on project.id = project_member.project_id + where project_member.user_email = {user} + """ + ).bindByName('user -> user).map(simple).list.apply().toSeq + } + + /** + * Update a project. + */ + def rename(id: Long, newName: String)(implicit session: DBSession = AutoSession) { + SQL("update project set name = {name} where id = {id}") + .bindByName('id -> id, 'name -> newName).update.apply() + } + + /** + * Delete a project. + */ + def delete(id: Long)(implicit session: DBSession = AutoSession) { + SQL("delete from project where id = {id}").bindByName('id -> id).update.apply() + } + + /** + * Delete all project in a folder + */ + def deleteInFolder(folder: String)(implicit session: DBSession = AutoSession) { + SQL("delete from project where folder = {folder}").bindByName('folder -> folder).update.apply() + } + + /** + * Rename a folder + */ + def renameFolder(folder: String, newName: String)(implicit session: DBSession = AutoSession) { + SQL("update project set folder = {newName} where folder = {folder}") + .bindByName('folder -> folder, 'newName -> newName).update.apply() + } + + /** + * Retrieve project member + */ + def membersOf(project: Long)(implicit session: DBSession = AutoSession): Seq[User] = { + SQL( + """ + select user.* from user + join project_member on project_member.user_email = user.email + where project_member.project_id = {project} + """ + ).bindByName('project -> project).map(User.simple).list.apply().toSeq + } + + /** + * Add a member to the project team. + */ + def addMember(project: Long, user: String)(implicit session: DBSession = AutoSession) { + SQL("insert into project_member values({project}, {user})") + .bindByName('project -> project, 'user -> user).map(simple).update.apply() + } + + /** + * Remove a member from the project team. + */ + def removeMember(project: Long, user: String)(implicit session: DBSession = AutoSession) { + SQL("delete from project_member where project_id = {project} and user_email = {user}") + .bindByName('project -> project, 'user -> user).update.apply() + } + + /** + * Check if a user is a member of this project + */ + def isMember(project: Long, user: String)(implicit session: DBSession = AutoSession): Boolean = { + SQL( + """ + select count(user.email) = 1 as is_member from user + join project_member on project_member.user_email = user.email + where project_member.project_id = {project} and user.email = {user} + """ + ).bindByName('project -> project, 'user -> user) + .map(rs => rs.boolean("is_member").asInstanceOf[Boolean]).single.apply().getOrElse(false) + } + + /** + * Create a Project. + */ + def create(project: NewProject, members: Seq[String])(implicit session: DBSession = AutoSession): Project = { + // Insert the project + val newId: Long = SQL("select next value for project_seq as v from dual") + .map(rs => rs.long("v")).single.apply().get + SQL( + """ + insert into project (id, name, folder) values ( + {id}, {name}, {folder} + ) + """ + ).bindByName('id -> newId, 'name -> project.name, 'folder -> project.folder).update.apply() + // Add members + members.foreach { email => + SQL("insert into project_member values ({id}, {email})") + .bindByName('id -> newId, 'email -> email).update.apply() + } + Project(id = newId, name = project.name, folder = project.folder) + } + +} diff --git a/scalikejdbc/app/models/Task.scala b/scalikejdbc/app/models/Task.scala new file mode 100644 index 0000000..3e4cbc0 --- /dev/null +++ b/scalikejdbc/app/models/Task.scala @@ -0,0 +1,180 @@ +package models + +import java.util.Date + +import scalikejdbc._ + +case class NewTask( + folder: String, + project: Long, + title: String, + done: Boolean, + dueDate: Option[Date], + assignedTo: Option[String] +) + +case class Task( + id: 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 = (rs: WrappedResultSet) => Task( + id = rs.long("id"), + folder = rs.string("folder"), + project = rs.long("project"), + title = rs.string("title"), + done = rs.boolean("done"), + dueDate = Option(rs.timestamp("due_date")), + assignedTo = Option(rs.string("assigned_to")) + ) + + val withProject = (rs: WrappedResultSet) => ( + Task( + id = rs.long("task_id"), + folder = rs.string("folder"), + project = rs.long("project_id"), + title = rs.string("title"), + done = rs.boolean("done"), + dueDate = Option(rs.timestamp("due_date")), + assignedTo = Option(rs.string("assigned_to")) + ), + Project( + id = rs.long("project_id"), + folder = rs.string("folder"), + name = rs.string("project_name") + ) + ) + + // -- Queries + + /** + * Retrieve a Task from the id. + */ + def findById(id: Long)(implicit session: DBSession = AutoSession): Option[Task] = { + SQL("select * from task where id = {id}").bindByName('id -> id).map(simple).single.apply() + } + + /** + * Retrieve todo tasks for the user. + */ + def findTodoInvolving(user: String)(implicit session: DBSession = AutoSession): Seq[(Task,Project)] = { + SQL( + """ + select + task.id as task_id, + project.id as project_id, + project.name as project_name, + * + 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} + """ + ).bindByName('user -> user).map(withProject).list.apply().toSeq + } + + /** + * Find tasks related to a project + */ + def findByProject(project: Long)(implicit session: DBSession = AutoSession): Seq[Task] = { + SQL( + """ + select * from task + where task.project = {project} + """ + ).bindByName('project -> project).map(simple).list.apply().toSeq + } + + /** + * Delete a task + */ + def delete(id: Long)(implicit session: DBSession = AutoSession) { + SQL("delete from task where id = {id}").bindByName('id -> id).update.apply() + } + + /** + * Delete all task in a folder. + */ + def deleteInFolder(projectId: Long, folder: String)(implicit session: DBSession = AutoSession) { + SQL("delete from task where project = {project} and folder = {folder}") + .bindByName('project -> projectId, 'foler -> folder).update.apply() + } + + /** + * Mark a task as done or not + */ + def markAsDone(taskId: Long, done: Boolean)(implicit session: DBSession = AutoSession) { + SQL("update task set done = {done} where id = {id}") + .bindByName('id -> taskId, 'done -> done).update.apply() + } + + /** + * Rename a folder. + */ + def renameFolder(projectId: Long, folder: String, newName: String)(implicit session: DBSession = AutoSession) { + SQL("update task set folder = {newFolder} where folder = {folder} and project = {project}") + .bindByName('folder -> folder, 'newFolder -> newName, 'project -> projectId).update.apply() + } + + /** + * Check if a user is the owner of this task + */ + def isOwner(task: Long, user: String)(implicit session: DBSession = AutoSession): Boolean = { + SQL( + """ + select count(task.id) = 1 as v from task + join project on task.project = project.id + join project_member on project_member.project_id = project.id + where project_member.user_email = {user} and task.id = {task} + """ + ).bindByName('user -> user, 'task -> task) + .map(rs => rs.boolean("v").asInstanceOf[Boolean]).single.apply().getOrElse(false) + } + + /** + * Create a Task. + */ + def create(task: NewTask)(implicit session: DBSession = AutoSession): Task = { + val newId = SQL("select next value for task_seq as v from dual").map(rs => rs.long("v")).single.apply().get + SQL( + """ + insert into task (id, folder, project, title, done, due_date, assigned_to) values ( + {id}, {folder}, {project}, {title}, {done}, {dueDate}, {assignedTo} + ) + """ + ).bindByName( + 'id -> newId, + 'folder -> task.folder, + 'project -> task.project, + 'title -> task.title, + 'done -> task.done, + 'dueDate -> task.dueDate, + 'assignedTo -> task.assignedTo + ).update.apply() + + Task( + id = newId, + folder = task.folder, + project = task.project, + title = task.title, + done = task.done, + dueDate = task.dueDate, + assignedTo = task.assignedTo + ) + } + +} diff --git a/scalikejdbc/app/models/User.scala b/scalikejdbc/app/models/User.scala new file mode 100644 index 0000000..1e20f20 --- /dev/null +++ b/scalikejdbc/app/models/User.scala @@ -0,0 +1,66 @@ +package models + +import scalikejdbc._ + +case class User(email: String, name: String, password: String) + +object User { + + // -- Parsers + + /** + * Parse a User from a ResultSet + */ + val simple = (rs: WrappedResultSet) => User( + rs.string("user.email"), + rs.string("user.name"), + rs.string("user.password") + ) + + // -- Queries + + /** + * Retrieve a User from email. + */ + def findByEmail(email: String)(implicit session: DBSession = AutoSession): Option[User] = { + SQL("select * from user where email = {email}").bindByName('email -> email).map(simple).single.apply() + } + + /** + * Retrieve all users. + */ + def findAll()(implicit session: DBSession = AutoSession): Seq[User] = { + SQL("select * from user").map(simple).list.apply().toSeq + } + + /** + * Authenticate a User. + */ + def authenticate(email: String, password: String)(implicit session: DBSession = AutoSession): Option[User] = { + SQL( + """ + select * from user where + email = {email} and password = {password} + """ + ).bindByName('email -> email, 'password -> password).map(simple).single.apply() + } + + /** + * Create a User. + */ + def create(user: User)(implicit session: DBSession = AutoSession): User = { + SQL( + """ + insert into user values ( + {email}, {name}, {password} + ) + """ + ).bindByName( + 'email -> user.email, + 'name -> user.name, + 'password -> user.password + ).update.apply() + user + } + +} diff --git a/scalikejdbc/app/scalikejdbc/PlayPlugin.scala b/scalikejdbc/app/scalikejdbc/PlayPlugin.scala new file mode 100644 index 0000000..ca32c32 --- /dev/null +++ b/scalikejdbc/app/scalikejdbc/PlayPlugin.scala @@ -0,0 +1,88 @@ +/* + * Copyright 2012 Kazuhiro Sera + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +package scalikejdbc + +import play.api._ + +/** + * The Play plugin to use ScalikeJDBC + */ +class PlayPlugin(app: Application) extends Plugin { + + import PlayPlugin._ + + private lazy val dbConfig = app.configuration.getConfig("db").getOrElse(Configuration.empty) + + dbConfig.subKeys map { + name => + def load(name: String): (String, String, String, ConnectionPoolSettings) = { + implicit val config = dbConfig + Class.forName(require(name, "driver")) + val default = new ConnectionPoolSettings + val settings = new ConnectionPoolSettings( + initialSize = opt(name, "poolInitialSize").map(v => v.toInt).getOrElse(default.initialSize), + maxSize = opt(name, "poolMaxSize").map(v => v.toInt).getOrElse(default.maxSize), + validationQuery = opt(name, "poolValidationQuery").getOrElse(default.validationQuery) + ) + (require(name, "url"), opt(name, "user").getOrElse(""), opt(name, "password").getOrElse(""), settings) + } + + name match { + case "global" => + Logger(classOf[PlayPlugin]).warn( + "Configuration with \"db.global\" is ignored. Use \"scalikejdbc.global\" instead.") + case "default" => + val (url, user, password, settings) = load(name) + ConnectionPool.singleton(url, user, password, settings) + case _ => + val (url, user, password, settings) = load(name) + ConnectionPool.add(Symbol(name), url, user, password, settings) + } + + } + + private lazy val globalConfig = app.configuration.getConfig("scalikejdbc.global").getOrElse(Configuration.empty) + + private val loggingSQLAndTime = "loggingSQLAndTime" + opt(loggingSQLAndTime, "enabled")(globalConfig).map(_.toBoolean).foreach { + enabled => + implicit val config = globalConfig + val default = new LoggingSQLAndTimeSettings + GlobalSettings.loggingSQLAndTime = new LoggingSQLAndTimeSettings( + enabled = enabled, + logLevel = opt(loggingSQLAndTime, "logLevel").map(v => Symbol(v)).getOrElse(default.logLevel), + warningEnabled = opt(loggingSQLAndTime, "warningEnabled").map(_.toBoolean).getOrElse(default.warningEnabled), + warningThresholdMillis = opt(loggingSQLAndTime, "warningThresholdMillis").map(_.toLong).getOrElse(default.warningThresholdMillis), + warningLogLevel = opt(loggingSQLAndTime, "warningLogLevel").map(v => Symbol(v)).getOrElse(default.warningLogLevel) + ) + } + +} + +object PlayPlugin { + + def opt(name: String, key: String)(implicit config: Configuration): Option[String] = { + config.getString(name + "." + key) + } + + def require(name: String, key: String)(implicit config: Configuration): String = { + config.getString(name + "." + key) getOrElse { + throw config.reportError(name, "Missing configuration [db." + name + "." + key + "]") + } + } + +} + diff --git a/scalikejdbc/app/views/dashboard.scala.html b/scalikejdbc/app/views/dashboard.scala.html new file mode 100644 index 0000000..3aef765 --- /dev/null +++ b/scalikejdbc/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 +
    +
      + @projectTasks.map { + case (task, _) => { + @tasks.item(task, isEditable = false) + } + } +
    +
    + } + } +
    + +} + diff --git a/scalikejdbc/app/views/login.scala.html b/scalikejdbc/app/views/login.scala.html new file mode 100644 index 0000000..277c29d --- /dev/null +++ b/scalikejdbc/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/scalikejdbc/app/views/main.scala.html b/scalikejdbc/app/views/main.scala.html new file mode 100644 index 0000000..53df511 --- /dev/null +++ b/scalikejdbc/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/scalikejdbc/app/views/projects/group.scala.html b/scalikejdbc/app/views/projects/group.scala.html new file mode 100644 index 0000000..1d39eb3 --- /dev/null +++ b/scalikejdbc/app/views/projects/group.scala.html @@ -0,0 +1,19 @@ +@(group: String, projects: Seq[Project] = Nil) + +
  • + +

    @group

    + Loading +
    +
    Options
    +
    + + +
    +
    +
      + @projects.map { project => + @views.html.projects.item(project) + } +
    +
  • diff --git a/scalikejdbc/app/views/projects/item.scala.html b/scalikejdbc/app/views/projects/item.scala.html new file mode 100644 index 0000000..d465c8e --- /dev/null +++ b/scalikejdbc/app/views/projects/item.scala.html @@ -0,0 +1,7 @@ +@(project: Project) + +
  • + @project.name + + Loading +
  • diff --git a/scalikejdbc/app/views/tasks/folder.scala.html b/scalikejdbc/app/views/tasks/folder.scala.html new file mode 100644 index 0000000..7e2d9a2 --- /dev/null +++ b/scalikejdbc/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 +
    +
      + @tasks.map { task => + @views.html.tasks.item( task ) + } +
    +
    + + + +
    + +
    + +
    +
    + diff --git a/scalikejdbc/app/views/tasks/index.scala.html b/scalikejdbc/app/views/tasks/index.scala.html new file mode 100644 index 0000000..e77b146 --- /dev/null +++ b/scalikejdbc/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/scalikejdbc/app/views/tasks/item.scala.html b/scalikejdbc/app/views/tasks/item.scala.html new file mode 100644 index 0000000..55dde8a --- /dev/null +++ b/scalikejdbc/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/scalikejdbc/conf/application.conf b/scalikejdbc/conf/application.conf new file mode 100644 index 0000000..42a7f3c --- /dev/null +++ b/scalikejdbc/conf/application.conf @@ -0,0 +1,49 @@ +# 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" +#db.default.user=sa +#db.default.password=sa + +# ScalikeJDBC original configuration +#db.default.poolInitialSize=10 +#db.default.poolMaxSize=10 +#db.default.poolValidationQuery= + +db.global.loggingSQLAndTime.enabled=true + +scalikejdbc.global.loggingSQLAndTime.enabled=true +scalikejdbc.global.loggingSQLAndTime.logLevel=debug +scalikejdbc.global.loggingSQLAndTime.warningEnabled=true +scalikejdbc.global.loggingSQLAndTime.warningThresholdMillis=5 +scalikejdbc.global.loggingSQLAndTime.warningLogLevel=warn + +# You can disable the default DB plugin +dbplugin=disabled +evolutionplugin=disabled + +# Logger +# ~~~~~ +# You can also configure logback (http://logback.qos.ch/), by providing a logger.xml file in the conf directory . + +# Root logger: +logger.root=DEBUG + +# Logger used by the framework: +logger.play=DEBUG + +# Logger provided to your application: +logger.application=DEBUG + diff --git a/scalikejdbc/conf/play.plugins b/scalikejdbc/conf/play.plugins new file mode 100644 index 0000000..c599549 --- /dev/null +++ b/scalikejdbc/conf/play.plugins @@ -0,0 +1,2 @@ +10000:scalikejdbc.PlayPlugin + diff --git a/scalikejdbc/conf/routes b/scalikejdbc/conf/routes new file mode 100644 index 0000000..7d33254 --- /dev/null +++ b/scalikejdbc/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/scalikejdbc/project/Build.scala b/scalikejdbc/project/Build.scala new file mode 100644 index 0000000..87edfc0 --- /dev/null +++ b/scalikejdbc/project/Build.scala @@ -0,0 +1,21 @@ +import sbt._ +import Keys._ + +import PlayProject._ + +object ApplicationBuild extends Build { + + val appName = "zentask" + val appVersion = "1.0" + + val appDependencies = Seq( + "com.github.seratch" %% "scalikejdbc" % "[1.3,)" + ) + + val main = PlayProject(appName, appVersion, appDependencies, mainLang = SCALA).settings( + resolvers += "Sonatype OSS" at "http://oss.sonatype.org/content/repositories/releases", + externalResolvers ~= (_.filter(_.name != "Scala-Tools Maven2 Repository")) + ) + +} + diff --git a/scalikejdbc/project/plugins.sbt b/scalikejdbc/project/plugins.sbt new file mode 100644 index 0000000..f837547 --- /dev/null +++ b/scalikejdbc/project/plugins.sbt @@ -0,0 +1,13 @@ +// Comment to get more information during initialization +logLevel := Level.Warn + +// The Typesafe repository +resolvers += "Typesafe repository" at "http://repo.typesafe.com/typesafe/releases/" + +resolvers += "idea" at "http://mpeltonen.github.com/maven/" + +// Use the Play sbt plugin for Play projects +addSbtPlugin("play" % "sbt-plugin" % Option(System.getProperty("play.version")).getOrElse("2.0.2")) + +addSbtPlugin("com.github.mpeltonen" % "sbt-idea" % "1.0.0") + diff --git a/scalikejdbc/public/images/arrow-left.png b/scalikejdbc/public/images/arrow-left.png new file mode 100644 index 0000000..9458ca1 Binary files /dev/null and b/scalikejdbc/public/images/arrow-left.png differ diff --git a/scalikejdbc/public/images/breadcrumb-1.png b/scalikejdbc/public/images/breadcrumb-1.png new file mode 100644 index 0000000..2b48824 Binary files /dev/null and b/scalikejdbc/public/images/breadcrumb-1.png differ diff --git a/scalikejdbc/public/images/breadcrumb-2.png b/scalikejdbc/public/images/breadcrumb-2.png new file mode 100644 index 0000000..c77aa79 Binary files /dev/null and b/scalikejdbc/public/images/breadcrumb-2.png differ diff --git a/scalikejdbc/public/images/breadcrumb.png b/scalikejdbc/public/images/breadcrumb.png new file mode 100644 index 0000000..6033bd9 Binary files /dev/null and b/scalikejdbc/public/images/breadcrumb.png differ diff --git a/scalikejdbc/public/images/icons/addRemove.png b/scalikejdbc/public/images/icons/addRemove.png new file mode 100644 index 0000000..ad2bdb5 Binary files /dev/null and b/scalikejdbc/public/images/icons/addRemove.png differ diff --git a/scalikejdbc/public/images/icons/clock.png b/scalikejdbc/public/images/icons/clock.png new file mode 100644 index 0000000..287f369 Binary files /dev/null and b/scalikejdbc/public/images/icons/clock.png differ diff --git a/scalikejdbc/public/images/icons/delete.png b/scalikejdbc/public/images/icons/delete.png new file mode 100644 index 0000000..d3ba1fa Binary files /dev/null and b/scalikejdbc/public/images/icons/delete.png differ diff --git a/scalikejdbc/public/images/icons/drawer.folder.png b/scalikejdbc/public/images/icons/drawer.folder.png new file mode 100644 index 0000000..312c269 Binary files /dev/null and b/scalikejdbc/public/images/icons/drawer.folder.png differ diff --git a/scalikejdbc/public/images/icons/folder.png b/scalikejdbc/public/images/icons/folder.png new file mode 100644 index 0000000..c4b941e Binary files /dev/null and b/scalikejdbc/public/images/icons/folder.png differ diff --git a/scalikejdbc/public/images/icons/home.png b/scalikejdbc/public/images/icons/home.png new file mode 100644 index 0000000..646fd54 Binary files /dev/null and b/scalikejdbc/public/images/icons/home.png differ diff --git a/scalikejdbc/public/images/icons/options.png b/scalikejdbc/public/images/icons/options.png new file mode 100644 index 0000000..ff1b337 Binary files /dev/null and b/scalikejdbc/public/images/icons/options.png differ diff --git a/scalikejdbc/public/images/icons/user.png b/scalikejdbc/public/images/icons/user.png new file mode 100644 index 0000000..4493c0e Binary files /dev/null and b/scalikejdbc/public/images/icons/user.png differ diff --git a/scalikejdbc/public/images/icons/user2.png b/scalikejdbc/public/images/icons/user2.png new file mode 100644 index 0000000..f829ede Binary files /dev/null and b/scalikejdbc/public/images/icons/user2.png differ diff --git a/scalikejdbc/public/images/loading.gif b/scalikejdbc/public/images/loading.gif new file mode 100644 index 0000000..daa933c Binary files /dev/null and b/scalikejdbc/public/images/loading.gif differ diff --git a/scalikejdbc/public/images/pattern.png b/scalikejdbc/public/images/pattern.png new file mode 100644 index 0000000..a8eea3d Binary files /dev/null and b/scalikejdbc/public/images/pattern.png differ diff --git a/scalikejdbc/public/javascripts/backbone-min.js b/scalikejdbc/public/javascripts/backbone-min.js new file mode 100644 index 0000000..3f0d495 --- /dev/null +++ b/scalikejdbc/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/scalikejdbc/test/IntegrationSpec.scala b/scalikejdbc/test/IntegrationSpec.scala new file mode 100644 index 0000000..2ffd48a --- /dev/null +++ b/scalikejdbc/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") + } + } + + } + +} diff --git a/scalikejdbc/test/models/UserSpec.scala b/scalikejdbc/test/models/UserSpec.scala new file mode 100644 index 0000000..cffdc4d --- /dev/null +++ b/scalikejdbc/test/models/UserSpec.scala @@ -0,0 +1,84 @@ +package models + +import org.specs2.mutable._ + +import scalikejdbc.NamedDB + +class UserSpec extends Specification { + + object Fixture { + + import scalikejdbc._ + + ConnectionPool.add("UserSpec", "jdbc:h2:mem:UserSpec", "", "") + NamedDB("UserSpec") autoCommit { + implicit session => + + val ddl = """ +drop table user if exists; +create table user ( + email varchar(255) not null primary key, + name varchar(255) not null, + password varchar(255) not null +); + +drop table project if exists; +create table project ( + id bigint not null primary key, + name varchar(255) not null, + folder varchar(255) not null +); + +drop sequence project_seq if exists; +create sequence project_seq start with 1000; + +drop table project_member if exists; +create table project_member ( + project_id bigint not null, + user_email varchar(255) not null, + foreign key(project_id) references project(id) on delete cascade, + foreign key(user_email) references user(email) on delete cascade +); + +drop table task if exists; +create table task ( + id bigint not null primary key, + title varchar(255) not null, + done boolean, + due_date timestamp, + assigned_to varchar(255), + project bigint not null, + folder varchar(255), + foreign key(assigned_to) references user(email) on delete set null, + foreign key(project) references project(id) on delete cascade +); + +drop sequence task_seq if exists; +create sequence task_seq start with 1000; + """ + + SQL(ddl).execute.apply() + } + + } + + "User" should { + "have #create and #findByEmail" in { + NamedDB("UserSpec") localTx { + implicit session => + val user = User.findByEmail("seratch@gmail.com").getOrElse { + User.create( + User( + email = "seratch@gmail.com", + name = "seratch", + password = "play20" + ) + ) + } + user.name must equalTo("seratch") + } + } + } + +} +