diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 00000000..e17fde3f --- /dev/null +++ b/app/controllers/users_controller.rb @@ -0,0 +1,11 @@ +class UsersController < ApplicationController + + # Currently used to display users nick and ids for autocomplete + def index + @users = User.where("nick LIKE ?", "%#{params[:q]}%") + respond_to do |format| + format.json { render :json => @users.map { |u| {:id => u.id, :name => u.nick} } } + end + end + +end diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb new file mode 100644 index 00000000..2310a240 --- /dev/null +++ b/app/helpers/users_helper.rb @@ -0,0 +1,2 @@ +module UsersHelper +end diff --git a/app/models/task.rb b/app/models/task.rb index 62a95699..5857a37a 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -27,13 +27,13 @@ class Task < ActiveRecord::Base def enough_users_assigned? assignments.find_all_by_accepted(true).size >= required_users ? true : false end - - # extracts nicknames from a comma seperated string + + # Get users from comma seperated ids # and makes the users responsible for the task - def user_list=(string) - @user_list = string.split(%r{,\s*}) - new_users = @user_list - users.collect(&:nick) - old_users = users.reject { |user| @user_list.include?(user.nick) } + def user_list=(ids) + list = ids.split(",") + new_users = list - users.collect(&:id) + old_users = users.reject { |user| list.include?(user.id) } logger.debug "New users: #{new_users}" logger.debug "Old users: #{old_users}" @@ -44,8 +44,8 @@ class Task < ActiveRecord::Base assignments.find(:all, :conditions => ["user_id IN (?)", old_users.collect(&:id)]).each(&:destroy) end # create new assignments - new_users.each do |nick| - user = User.find_by_nick(nick) + new_users.each do |id| + user = User.find(id) if user.blank? errors.add(:user_list) else @@ -62,7 +62,7 @@ class Task < ActiveRecord::Base end def user_list - @user_list ||= users.collect(&:nick).join(", ") + @user_list ||= users.collect(&:id).join(", ") end private diff --git a/app/views/layouts/application.haml b/app/views/layouts/application.haml index e64710e2..bfd1c566 100644 --- a/app/views/layouts/application.haml +++ b/app/views/layouts/application.haml @@ -3,12 +3,12 @@ %head %meta{"http-equiv" => "content-type", :content => "text/html;charset=UTF-8"} %title= "FoodSoft - " + (yield(:title) or controller.controller_name) - = stylesheet_link_tag 'main', 'rails_messages', 'nav', 'simple_form', :cache => "all_cached" + = stylesheet_link_tag 'main', 'rails_messages', 'nav', 'simple_form', 'token-input', :cache => "all_cached" = stylesheet_link_tag "print", :media => "print" - = javascript_include_tag 'jquery.min', 'jquery-ui.min', 'jquery_ujs', 'application', 'ordering', :cache => "all_cached" + = javascript_include_tag 'jquery.min', 'jquery-ui.min', 'jquery_ujs', 'jquery.tokeninput', 'application', 'ordering', :cache => "all_cached" = yield(:head) %body #logininfo= render :partial => 'shared/loginInfo' diff --git a/app/views/tasks/_form.html.haml b/app/views/tasks/_form.html.haml index ecdcc0ab..032178d9 100644 --- a/app/views/tasks/_form.html.haml +++ b/app/views/tasks/_form.html.haml @@ -1,8 +1,17 @@ +- content_for :head do + :javascript + $(function() { + $("#task_user_list").tokenInput("#{users_path(:format => :json)}", { + crossDomain: false, + prePopulate: $("#task_user_list").data("pre") + }); + }); + = simple_form_for @task do |f| = f.input :name = f.input :description = f.input :duration, :as => :select, :collection => 1..3 - = f.input :user_list, :as => :string + = f.input :user_list, :as => :string, :input_html => { 'data-pre' => @task.users.map { |u| {:id => u.id, :name => u.nick} }.to_json } = f.input :required_users = f.association :workgroup = f.input :due_date, :include_blank => true diff --git a/config/routes.rb b/config/routes.rb index 7c55ceb1..3994aaaa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -23,7 +23,6 @@ Foodsoft::Application.routes.draw do match '/home/profile' => 'home#profile', :as => 'my_profile' match '/home/ordergroup' => 'home#ordergroup', :as => 'my_ordergroup' - ############ Wiki resources :pages do @@ -157,6 +156,8 @@ Foodsoft::Application.routes.draw do ############## The rest + resources :users, :only => [:index] + match '/:controller(/:action(/:id))' end # End of /:foodcoop scope diff --git a/public/javascripts/jquery.tokeninput.js b/public/javascripts/jquery.tokeninput.js new file mode 100644 index 00000000..4618d67a --- /dev/null +++ b/public/javascripts/jquery.tokeninput.js @@ -0,0 +1,718 @@ +/* + * jQuery Plugin: Tokenizing Autocomplete Text Entry + * Version 1.4.2 + * + * Copyright (c) 2009 James Smith (http://loopj.com) + * Licensed jointly under the GPL and MIT licenses, + * choose which one suits your project best! + * + */ + +(function ($) { +// Default settings +var DEFAULT_SETTINGS = { + hintText: "Type in a search term", + noResultsText: "No results", + searchingText: "Searching...", + deleteText: "×", + searchDelay: 300, + minChars: 1, + tokenLimit: null, + jsonContainer: null, + method: "GET", + contentType: "json", + queryParam: "q", + tokenDelimiter: ",", + preventDuplicates: false, + prePopulate: null, + animateDropdown: true, + onResult: null, + onAdd: null, + onDelete: null +}; + +// Default classes to use when theming +var DEFAULT_CLASSES = { + tokenList: "token-input-list", + token: "token-input-token", + tokenDelete: "token-input-delete-token", + selectedToken: "token-input-selected-token", + highlightedToken: "token-input-highlighted-token", + dropdown: "token-input-dropdown", + dropdownItem: "token-input-dropdown-item", + dropdownItem2: "token-input-dropdown-item2", + selectedDropdownItem: "token-input-selected-dropdown-item", + inputToken: "token-input-input-token" +}; + +// Input box position "enum" +var POSITION = { + BEFORE: 0, + AFTER: 1, + END: 2 +}; + +// Keys "enum" +var KEY = { + BACKSPACE: 8, + TAB: 9, + ENTER: 13, + ESCAPE: 27, + SPACE: 32, + PAGE_UP: 33, + PAGE_DOWN: 34, + END: 35, + HOME: 36, + LEFT: 37, + UP: 38, + RIGHT: 39, + DOWN: 40, + NUMPAD_ENTER: 108, + COMMA: 188 +}; + + +// Expose the .tokenInput function to jQuery as a plugin +$.fn.tokenInput = function (url_or_data, options) { + var settings = $.extend({}, DEFAULT_SETTINGS, options || {}); + + return this.each(function () { + new $.TokenList(this, url_or_data, settings); + }); +}; + + +// TokenList class for each input +$.TokenList = function (input, url_or_data, settings) { + // + // Initialization + // + + // Configure the data source + if($.type(url_or_data) === "string") { + // Set the url to query against + settings.url = url_or_data; + + // Make a smart guess about cross-domain if it wasn't explicitly specified + if(settings.crossDomain === undefined) { + if(settings.url.indexOf("://") === -1) { + settings.crossDomain = false; + } else { + settings.crossDomain = (location.href.split(/\/+/g)[1] !== settings.url.split(/\/+/g)[1]); + } + } + } else if($.type(url_or_data) === "array") { + // Set the local data to search through + settings.local_data = url_or_data; + } + + // Build class names + if(settings.classes) { + // Use custom class names + settings.classes = $.extend({}, DEFAULT_CLASSES, settings.classes); + } else if(settings.theme) { + // Use theme-suffixed default class names + settings.classes = {}; + $.each(DEFAULT_CLASSES, function(key, value) { + settings.classes[key] = value + "-" + settings.theme; + }); + } else { + settings.classes = DEFAULT_CLASSES; + } + + + // Save the tokens + var saved_tokens = []; + + // Keep track of the number of tokens in the list + var token_count = 0; + + // Basic cache to save on db hits + var cache = new $.TokenList.Cache(); + + // Keep track of the timeout, old vals + var timeout; + var input_val; + + // Create a new text input an attach keyup events + var input_box = $("") + .css({ + outline: "none" + }) + .focus(function () { + if (settings.tokenLimit === null || settings.tokenLimit !== token_count) { + show_dropdown_hint(); + } + }) + .blur(function () { + hide_dropdown(); + }) + .bind("keyup keydown blur update", resize_input) + .keydown(function (event) { + var previous_token; + var next_token; + + switch(event.keyCode) { + case KEY.LEFT: + case KEY.RIGHT: + case KEY.UP: + case KEY.DOWN: + if(!$(this).val()) { + previous_token = input_token.prev(); + next_token = input_token.next(); + + if((previous_token.length && previous_token.get(0) === selected_token) || (next_token.length && next_token.get(0) === selected_token)) { + // Check if there is a previous/next token and it is selected + if(event.keyCode === KEY.LEFT || event.keyCode === KEY.UP) { + deselect_token($(selected_token), POSITION.BEFORE); + } else { + deselect_token($(selected_token), POSITION.AFTER); + } + } else if((event.keyCode === KEY.LEFT || event.keyCode === KEY.UP) && previous_token.length) { + // We are moving left, select the previous token if it exists + select_token($(previous_token.get(0))); + } else if((event.keyCode === KEY.RIGHT || event.keyCode === KEY.DOWN) && next_token.length) { + // We are moving right, select the next token if it exists + select_token($(next_token.get(0))); + } + } else { + var dropdown_item = null; + + if(event.keyCode === KEY.DOWN || event.keyCode === KEY.RIGHT) { + dropdown_item = $(selected_dropdown_item).next(); + } else { + dropdown_item = $(selected_dropdown_item).prev(); + } + + if(dropdown_item.length) { + select_dropdown_item(dropdown_item); + } + return false; + } + break; + + case KEY.BACKSPACE: + previous_token = input_token.prev(); + + if(!$(this).val().length) { + if(selected_token) { + delete_token($(selected_token)); + } else if(previous_token.length) { + select_token($(previous_token.get(0))); + } + + return false; + } else if($(this).val().length === 1) { + hide_dropdown(); + } else { + // set a timeout just long enough to let this function finish. + setTimeout(function(){do_search();}, 5); + } + break; + + case KEY.TAB: + case KEY.ENTER: + case KEY.NUMPAD_ENTER: + case KEY.COMMA: + if(selected_dropdown_item) { + add_token($(selected_dropdown_item)); + return false; + } + break; + + case KEY.ESCAPE: + hide_dropdown(); + return true; + + default: + if(String.fromCharCode(event.which)) { + // set a timeout just long enough to let this function finish. + setTimeout(function(){do_search();}, 5); + } + break; + } + }); + + // Keep a reference to the original input box + var hidden_input = $(input) + .hide() + .val("") + .focus(function () { + input_box.focus(); + }) + .blur(function () { + input_box.blur(); + }); + + // Keep a reference to the selected token and dropdown item + var selected_token = null; + var selected_dropdown_item = null; + + // The list to store the token items in + var token_list = $("
"+ value +"
"+settings.searchingText+"
"); + show_dropdown(); + } + } + + function show_dropdown_hint () { + if(settings.hintText) { + dropdown.html(""+settings.hintText+"
"); + show_dropdown(); + } + } + + // Highlight the query part of the search term + function highlight_term(value, term) { + return value.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)(" + term + ")(?![^<>]*>)(?![^&;]+;)", "gi"), "$1"); + } + + // Populate the results dropdown with some results + function populate_dropdown (query, results) { + if(results && results.length) { + dropdown.empty(); + var dropdown_ul = $(""+settings.noResultsText+"
"); + show_dropdown(); + } + } + } + + // Highlight an item in the results dropdown + function select_dropdown_item (item) { + if(item) { + if(selected_dropdown_item) { + deselect_dropdown_item($(selected_dropdown_item)); + } + + item.addClass(settings.classes.selectedDropdownItem); + selected_dropdown_item = item.get(0); + } + } + + // Remove highlighting from an item in the results dropdown + function deselect_dropdown_item (item) { + item.removeClass(settings.classes.selectedDropdownItem); + selected_dropdown_item = null; + } + + // Do a search and show the "searching" dropdown if the input is longer + // than settings.minChars + function do_search() { + var query = input_box.val().toLowerCase(); + + if(query && query.length) { + if(selected_token) { + deselect_token($(selected_token), POSITION.AFTER); + } + + if(query.length >= settings.minChars) { + show_dropdown_searching(); + clearTimeout(timeout); + + timeout = setTimeout(function(){ + run_search(query); + }, settings.searchDelay); + } else { + hide_dropdown(); + } + } + } + + // Do the actual search + function run_search(query) { + var cached_results = cache.get(query); + if(cached_results) { + populate_dropdown(query, cached_results); + } else { + // Are we doing an ajax search or local data search? + if(settings.url) { + // Extract exisiting get params + var ajax_params = {}; + ajax_params.data = {}; + if(settings.url.indexOf("?") > -1) { + var parts = settings.url.split("?"); + ajax_params.url = parts[0]; + + var param_array = parts[1].split("&"); + $.each(param_array, function (index, value) { + var kv = value.split("="); + ajax_params.data[kv[0]] = kv[1]; + }); + } else { + ajax_params.url = settings.url; + } + + // Prepare the request + ajax_params.data[settings.queryParam] = query; + ajax_params.type = settings.method; + ajax_params.dataType = settings.contentType; + if(settings.crossDomain) { + ajax_params.dataType = "jsonp"; + } + + // Attach the success callback + ajax_params.success = function(results) { + if($.isFunction(settings.onResult)) { + results = settings.onResult.call(this, results); + } + cache.add(query, settings.jsonContainer ? results[settings.jsonContainer] : results); + + // only populate the dropdown if the results are associated with the active search query + if(input_box.val().toLowerCase() === query) { + populate_dropdown(query, settings.jsonContainer ? results[settings.jsonContainer] : results); + } + }; + + // Make the request + $.ajax(ajax_params); + } else if(settings.local_data) { + // Do the search through local data + var results = $.grep(settings.local_data, function (row) { + return row.name.toLowerCase().indexOf(query.toLowerCase()) > -1; + }); + + populate_dropdown(query, results); + } + } + } +}; + +// Really basic cache for the results +$.TokenList.Cache = function (options) { + var settings = $.extend({ + max_size: 500 + }, options); + + var data = {}; + var size = 0; + + var flush = function () { + data = {}; + size = 0; + }; + + this.add = function (query, results) { + if(size > settings.max_size) { + flush(); + } + + if(!data[query]) { + size += 1; + } + + data[query] = results; + }; + + this.get = function (query) { + return data[query]; + }; +}; +}(jQuery)); diff --git a/public/stylesheets/token-input.css b/public/stylesheets/token-input.css new file mode 100644 index 00000000..03bb01c4 --- /dev/null +++ b/public/stylesheets/token-input.css @@ -0,0 +1,113 @@ +/* Example tokeninput style #1: Token vertical list*/ +ul.token-input-list { + overflow: hidden; + height: auto !important; + height: 1%; + width: 400px; + border: 1px solid #999; + cursor: text; + font-size: 12px; + font-family: Verdana; + z-index: 999; + margin: 0; + padding: 0; + background-color: #fff; + list-style-type: none; + clear: left; +} + +ul.token-input-list li { + list-style-type: none; +} + +ul.token-input-list li input { + border: 0; + width: 350px; + padding: 3px 8px; + background-color: white; + -webkit-appearance: caret; +} + +li.token-input-token { + overflow: hidden; + height: auto !important; + height: 1%; + margin: 3px; + padding: 3px 5px; + background-color: #d0efa0; + color: #000; + font-weight: bold; + cursor: default; + display: block; +} + +li.token-input-token p { + float: left; + padding: 0; + margin: 0; +} + +li.token-input-token span { + float: right; + color: #777; + cursor: pointer; +} + +li.token-input-selected-token { + background-color: #08844e; + color: #fff; +} + +li.token-input-selected-token span { + color: #bbb; +} + +div.token-input-dropdown { + position: absolute; + width: 400px; + background-color: #fff; + overflow: hidden; + border-left: 1px solid #ccc; + border-right: 1px solid #ccc; + border-bottom: 1px solid #ccc; + cursor: default; + font-size: 12px; + font-family: Verdana; + z-index: 1; +} + +div.token-input-dropdown p { + margin: 0; + padding: 5px; + font-weight: bold; + color: #777; +} + +div.token-input-dropdown ul { + margin: 0; + padding: 0; +} + +div.token-input-dropdown ul li { + background-color: #fff; + padding: 3px; + list-style-type: none; +} + +div.token-input-dropdown ul li.token-input-dropdown-item { + background-color: #fafafa; +} + +div.token-input-dropdown ul li.token-input-dropdown-item2 { + background-color: #fff; +} + +div.token-input-dropdown ul li em { + font-weight: bold; + font-style: normal; +} + +div.token-input-dropdown ul li.token-input-selected-dropdown-item { + background-color: #d0efa0; +} + diff --git a/test/functional/users_controller_test.rb b/test/functional/users_controller_test.rb new file mode 100644 index 00000000..88e514c7 --- /dev/null +++ b/test/functional/users_controller_test.rb @@ -0,0 +1,9 @@ +require 'test_helper' + +class UsersControllerTest < ActionController::TestCase + test "should get index" do + get :index + assert_response :success + end + +end diff --git a/test/unit/helpers/users_helper_test.rb b/test/unit/helpers/users_helper_test.rb new file mode 100644 index 00000000..96af37a8 --- /dev/null +++ b/test/unit/helpers/users_helper_test.rb @@ -0,0 +1,4 @@ +require 'test_helper' + +class UsersHelperTest < ActionView::TestCase +end