Commit e68ef809aca10100a8b846104daf74dcb779489b

Authored by Larissa Reis
2 parents 011d76b5 61687fd1

Merge branch 'private-scraps' into 'master'

Private scraps

Write scraps that are visible for only the people or group marked.

This feature was implemented by Rodrigo Souto, I'm just creating the MR.

See merge request !997
app/controllers/public/profile_controller.rb
... ... @@ -19,6 +19,11 @@ class ProfileController < PublicController
19 19 @network_activities = @profile.tracked_notifications.visible.paginate(:per_page => 15, :page => params[:page]) if @network_activities.empty?
20 20 @activities = @profile.activities.paginate(:per_page => 15, :page => params[:page])
21 21 end
  22 +
  23 + # TODO Find a way to filter these through sql
  24 + @network_activities = filter_private_scraps(@network_activities)
  25 + @activities = filter_private_scraps(@activities)
  26 +
22 27 @tags = profile.article_tags
23 28 allow_access_to_page
24 29 end
... ... @@ -231,6 +236,7 @@ class ProfileController < PublicController
231 236 @scrap = Scrap.new(params[:scrap])
232 237 @scrap.sender= sender
233 238 @scrap.receiver= receiver
  239 + @scrap.marked_people = treat_followed_entries(params[:filter_followed])
234 240 @tab_action = params[:tab_action]
235 241 @message = @scrap.save ? _("Message successfully sent.") : _("You can't leave an empty message.")
236 242 activities = @profile.activities.paginate(:per_page => 15, :page => params[:page]) if params[:not_load_scraps].nil?
... ... @@ -253,6 +259,14 @@ class ProfileController < PublicController
253 259 end
254 260 end
255 261  
  262 + def search_followed
  263 + result = []
  264 + circles = find_by_contents(:circles, user, user.circles.where(:profile_type => 'Person'), params[:q])[:results]
  265 + followed = find_by_contents(:followed, user, Profile.followed_by(user), params[:q])[:results]
  266 + result = circles + followed
  267 + render :text => prepare_to_token_input_by_class(result).to_json
  268 + end
  269 +
256 270 def view_more_activities
257 271 @activities = @profile.activities.paginate(:per_page => 10, :page => params[:page])
258 272 render :partial => 'profile_activities_list', :locals => {:activities => @activities}
... ... @@ -434,7 +448,6 @@ class ProfileController < PublicController
434 448 end
435 449 end
436 450  
437   -
438 451 protected
439 452  
440 453 def check_access_to_profile
... ... @@ -480,4 +493,39 @@ class ProfileController < PublicController
480 493 render_not_found unless profile.allow_followers?
481 494 end
482 495  
  496 + def treat_followed_entries(entries)
  497 + return [] if entries.blank? || profile != user
  498 +
  499 + followed = []
  500 + entries.split(',').map do |entry|
  501 + klass, identifier = entry.split('_')
  502 + case klass
  503 + when 'Person'
  504 + followed << Person.find(identifier)
  505 + when 'Circle'
  506 + circle = Circle.find(identifier)
  507 + followed += Profile.in_circle(circle)
  508 + end
  509 + end
  510 + followed.uniq
  511 + end
  512 +
  513 + def filter_private_scraps(activities)
  514 + activities = Array(activities)
  515 + activities.delete_if do |item|
  516 + if item.kind_of?(ProfileActivity)
  517 + target = item.activity
  518 + owner = profile
  519 + else
  520 + target = item.target
  521 + owner = item.user
  522 + end
  523 + !environment.admins.include?(user) &&
  524 + owner != user &&
  525 + target.is_a?(Scrap) &&
  526 + target.marked_people.present? &&
  527 + !target.marked_people.include?(user)
  528 + end
  529 + activities
  530 + end
483 531 end
... ...
app/helpers/article_helper.rb
... ... @@ -160,6 +160,10 @@ module ArticleHelper
160 160 array.map { |object| {:label => object.name, :value => object.name} }
161 161 end
162 162  
  163 + def prepare_to_token_input_by_class(array)
  164 + array.map { |object| {:id => "#{object.class.name}_#{object.id || object.name}", :name => "#{object.name} (#{_(object.class.name)})", :class => object.class.name}}
  165 + end
  166 +
163 167 def cms_label_for_new_children
164 168 _('New article')
165 169 end
... ...
app/helpers/token_helper.rb
... ... @@ -5,10 +5,11 @@ module TokenHelper
5 5 end
6 6  
7 7 def token_input_field_tag(name, element_id, search_action, options = {}, text_field_options = {}, html_options = {})
8   - options[:min_chars] ||= 3
  8 + options[:min_chars] ||= 2
9 9 options[:hint_text] ||= _("Type in a search term")
10 10 options[:no_results_text] ||= _("No results")
11 11 options[:searching_text] ||= _("Searching...")
  12 + options[:placeholder] ||= 'null'
12 13 options[:search_delay] ||= 1000
13 14 options[:prevent_duplicates] ||= true
14 15 options[:backspace_delete_item] ||= false
... ... @@ -20,6 +21,9 @@ module TokenHelper
20 21 options[:on_delete] ||= 'null'
21 22 options[:on_ready] ||= 'null'
22 23 options[:query_param] ||= 'q'
  24 + options[:theme] ||= 'null'
  25 + options[:results_formatter] ||= 'null'
  26 + options[:token_formatter] ||= 'null'
23 27  
24 28 result = text_field_tag(name, nil, text_field_options.merge(html_options.merge({:id => element_id})))
25 29 result += javascript_tag("jQuery('##{element_id}')
... ... @@ -29,6 +33,7 @@ module TokenHelper
29 33 hintText: #{options[:hint_text].to_json},
30 34 noResultsText: #{options[:no_results_text].to_json},
31 35 searchingText: #{options[:searching_text].to_json},
  36 + placeholder: #{options[:placeholder].to_json},
32 37 searchDelay: #{options[:search_delay].to_json},
33 38 preventDuplicates: #{options[:prevent_duplicates].to_json},
34 39 backspaceDeleteItem: #{options[:backspace_delete_item].to_json},
... ... @@ -39,6 +44,9 @@ module TokenHelper
39 44 onAdd: #{options[:on_add]},
40 45 onDelete: #{options[:on_delete]},
41 46 onReady: #{options[:on_ready]},
  47 + theme: #{options[:theme] == 'null' ? options[:theme] : options[:theme].to_json},
  48 + resultsFormater: #{options[:results_formatter]},
  49 + tokenFormater: #{options[:token_formatter]},
42 50 });
43 51 ")
44 52 result += javascript_tag("jQuery('##{element_id}').focus();") if options[:focus]
... ...
app/jobs/notify_activity_to_profiles_job.rb
... ... @@ -19,8 +19,13 @@ class NotifyActivityToProfilesJob &lt; Struct.new(:tracked_action_id)
19 19 # Notify the user
20 20 ActionTrackerNotification.create(:profile_id => tracked_action.user.id, :action_tracker_id => tracked_action.id)
21 21  
22   - # Notify all followers
23   - ActionTrackerNotification.connection.execute("INSERT INTO action_tracker_notifications(profile_id, action_tracker_id) SELECT DISTINCT c.person_id, #{tracked_action.id} FROM profiles_circles AS p JOIN circles as c ON c.id = p.circle_id WHERE p.profile_id = #{tracked_action.user.id} AND (c.person_id NOT IN (SELECT atn.profile_id FROM action_tracker_notifications AS atn WHERE atn.action_tracker_id = #{tracked_action.id}))")
  22 + if target.is_a?(Scrap) && target.marked_people.present?
  23 + # Notify only marked people
  24 + ActionTrackerNotification.connection.execute("INSERT INTO action_tracker_notifications(profile_id, action_tracker_id) SELECT DISTINCT profiles.id, #{tracked_action.id} FROM profiles WHERE profiles.id IN (#{target.marked_people.map(&:id).join(',')})")
  25 + else
  26 + # Notify all followers
  27 + ActionTrackerNotification.connection.execute("INSERT INTO action_tracker_notifications(profile_id, action_tracker_id) SELECT DISTINCT c.person_id, #{tracked_action.id} FROM profiles_circles AS p JOIN circles as c ON c.id = p.circle_id WHERE p.profile_id = #{tracked_action.user.id} AND (c.person_id NOT IN (SELECT atn.profile_id FROM action_tracker_notifications AS atn WHERE atn.action_tracker_id = #{tracked_action.id}))")
  28 + end
24 29  
25 30 if tracked_action.user.is_a? Organization
26 31 ActionTrackerNotification.connection.execute "insert into action_tracker_notifications(profile_id, action_tracker_id) " +
... ...
app/models/circle.rb
1 1 class Circle < ApplicationRecord
  2 + SEARCHABLE_FIELDS = {
  3 + :name => {:label => _('Name'), :weight => 1}
  4 + }
  5 +
  6 + _('Circle')
  7 +
2 8 has_many :profile_followers
3 9 belongs_to :person
4 10  
... ...
app/models/person.rb
... ... @@ -121,6 +121,8 @@ class Person &lt; Profile
121 121 where 'profile_suggestions.suggestion_type = ? AND profile_suggestions.enabled = ?', 'Community', true
122 122 }, through: :suggested_profiles, source: :suggestion
123 123  
  124 + has_and_belongs_to_many :marked_scraps, :join_table => :private_scraps, :class_name => 'Scrap'
  125 +
124 126 scope :more_popular, -> { order 'friends_count DESC' }
125 127  
126 128 scope :abusers, -> {
... ...
app/models/scrap.rb
... ... @@ -2,7 +2,7 @@ class Scrap &lt; ApplicationRecord
2 2  
3 3 include SanitizeHelper
4 4  
5   - attr_accessible :content, :sender_id, :receiver_id, :scrap_id
  5 + attr_accessible :content, :sender_id, :receiver_id, :scrap_id, :marked_people
6 6  
7 7 SEARCHABLE_FIELDS = {
8 8 :content => {:label => _('Content'), :weight => 1},
... ... @@ -19,6 +19,8 @@ class Scrap &lt; ApplicationRecord
19 19 where profile_activities: {activity_type: 'Scrap'}
20 20 }, foreign_key: :activity_id, dependent: :destroy
21 21  
  22 + has_and_belongs_to_many :marked_people, :join_table => :private_scraps, :class_name => 'Person'
  23 +
22 24 after_create :create_activity
23 25 after_update :update_activity
24 26  
... ...
app/views/profile/_profile_wall.html.erb
1 1 <h3><%= _("%s's wall") % @profile.name %></h3>
2 2 <div id='leave_scrap'>
3 3 <%= flash[:error] %>
4   - <%= form_remote_tag :url => {:controller => 'profile', :action => 'leave_scrap', :tab_action => 'wall' }, :update => 'profile_activities', :success => "jQuery('#leave_scrap_content').val('')", :complete => "jQuery('#leave_scrap_form').removeClass('loading').find('*').attr('disabled', false)", :loading => "jQuery('#leave_scrap_form').addClass('loading').find('*').attr('disabled', true)", :html => {:id => 'leave_scrap_form' } do %>
5   - <%= limited_text_area :scrap, :content, 420, 'leave_scrap_content', :cols => 50, :rows => 2, :class => 'autogrow' %>
  4 + <%= form_remote_tag :url => {:controller => 'profile', :action => 'leave_scrap', :tab_action => 'wall' }, :update => 'profile_activities', :success => "jQuery('#leave_scrap_content').val(''); jQuery('#filter-followed').tokenInput('clear')", :complete => "jQuery('#leave_scrap_form').removeClass('loading').find('*').attr('disabled', false)", :loading => "jQuery('#leave_scrap_form').addClass('loading').find('*').attr('disabled', true)", :html => {:id => 'leave_scrap_form' } do %>
  5 + <%= limited_text_area :scrap, :content, 420, 'leave_scrap_content', :rows => 2, :class => 'autogrow' %>
  6 + <% if profile == user %>
  7 + <%= token_input_field_tag(:filter_followed, 'filter-followed', {:action => 'search_followed'}, {:theme => 'facebook', :placeholder => _('Filter followed, friends or group of friends to send them a private scrap...')}) %>
  8 + <% end %>
6 9 <%= submit_button :new, _('Share') %>
7 10 <% end %>
8 11 </div>
... ...
db/migrate/20160705162914_create_private_scraps.rb 0 → 100644
... ... @@ -0,0 +1,8 @@
  1 +class CreatePrivateScraps < ActiveRecord::Migration
  2 + def change
  3 + create_table :private_scraps do |t|
  4 + t.references :person
  5 + t.references :scrap
  6 + end
  7 + end
  8 +end
... ...
db/schema.rb
... ... @@ -11,7 +11,7 @@
11 11 #
12 12 # It's strongly recommended that you check this file into your version control system.
13 13  
14   -ActiveRecord::Schema.define(version: 20160608123748) do
  14 +ActiveRecord::Schema.define(version: 20160705162914) do
15 15  
16 16 # These are extensions that must be enabled in order to support this database
17 17 enable_extension "plpgsql"
... ... @@ -524,6 +524,11 @@ ActiveRecord::Schema.define(version: 20160608123748) do
524 524 t.datetime "updated_at"
525 525 end
526 526  
  527 + create_table "private_scraps", force: :cascade do |t|
  528 + t.integer "person_id"
  529 + t.integer "scrap_id"
  530 + end
  531 +
527 532 create_table "product_qualifiers", force: :cascade do |t|
528 533 t.integer "product_id"
529 534 t.integer "qualifier_id"
... ...
public/javascripts/jquery.tokeninput.js
1 1 /*
2 2 * jQuery Plugin: Tokenizing Autocomplete Text Entry
3   - * Version 1.5.0
4   - * Requires jQuery 1.6+
  3 + * Version 1.6.2
5 4 *
6 5 * Copyright (c) 2009 James Smith (http://loopj.com)
7 6 * Licensed jointly under the GPL and MIT licenses,
8 7 * choose which one suits your project best!
9 8 *
10 9 */
11   -
12   -(function ($) {
13   -// Default settings
14   -var DEFAULT_SETTINGS = {
15   - hintText: "Type in a search term",
16   - noResultsText: "No results",
17   - searchingText: "Searching...",
18   - deleteText: "&times;",
  10 +;(function ($) {
  11 + var DEFAULT_SETTINGS = {
  12 + // Search settings
  13 + method: "GET",
  14 + queryParam: "q",
19 15 searchDelay: 300,
20 16 minChars: 1,
21   - permanentDropdown: false,
22   - showAllResults: false,
23   - tokenLimit: null,
  17 + propertyToSearch: "name",
24 18 jsonContainer: null,
25   - method: "GET",
26 19 contentType: "json",
27   - queryParam: "q",
28   - tokenDelimiter: ",",
29   - preventDuplicates: false,
  20 + excludeCurrent: false,
  21 + excludeCurrentParameter: "x",
  22 +
  23 + // Prepopulation settings
30 24 prePopulate: null,
31 25 processPrePopulate: false,
  26 +
  27 + // Display settings
  28 + hintText: "Type in a search term",
  29 + noResultsText: "No results",
  30 + searchingText: "Searching...",
  31 + deleteText: "&#215;",
32 32 animateDropdown: true,
33   - dontAdd: false,
  33 + placeholder: null,
  34 + theme: null,
  35 + zindex: 999,
  36 + resultsLimit: null,
  37 +
  38 + enableHTML: false,
  39 +
  40 + resultsFormatter: function(item) {
  41 + var string = item[this.propertyToSearch];
  42 + return "<li>" + (this.enableHTML ? string : _escapeHTML(string)) + "</li>";
  43 + },
  44 +
  45 + tokenFormatter: function(item) {
  46 + var string = item[this.propertyToSearch];
  47 + return "<li><p>" + (this.enableHTML ? string : _escapeHTML(string)) + "</p></li>";
  48 + },
  49 +
  50 + // Tokenization settings
  51 + tokenLimit: null,
  52 + tokenDelimiter: ",",
  53 + preventDuplicates: false,
  54 + tokenValue: "id",
  55 +
  56 + // Behavioral settings
  57 + allowFreeTagging: false,
  58 + allowTabOut: false,
  59 + autoSelectFirstResult: false,
  60 +
  61 + // Callbacks
34 62 onResult: null,
  63 + onCachedResult: null,
35 64 onAdd: null,
  65 + onFreeTaggingAdd: null,
36 66 onDelete: null,
  67 + onReady: null,
  68 +
  69 + // Other settings
37 70 idPrefix: "token-input-",
38   - zindex: 999,
39   - backspaceDeleteItem: true
40   -};
41   -
42   -// Default classes to use when theming
43   -var DEFAULT_CLASSES = {
44   - tokenList: "token-input-list",
45   - token: "token-input-token",
46   - tokenDelete: "token-input-delete-token",
47   - selectedToken: "token-input-selected-token",
48   - highlightedToken: "token-input-highlighted-token",
49   - dropdown: "token-input-dropdown",
50   - dropdownItem: "token-input-dropdown-item",
51   - dropdownItem2: "token-input-dropdown-item2",
52   - selectedDropdownItem: "token-input-selected-dropdown-item",
53   - inputToken: "token-input-input-token",
54   - blurText: "token-input-blur-text",
55   -};
56   -
57   -// Input box position "enum"
58   -var POSITION = {
59   - BEFORE: 0,
60   - AFTER: 1,
61   - END: 2
62   -};
63   -
64   -// Keys "enum"
65   -var KEY = {
66   - BACKSPACE: 8,
67   - DELETE: 46,
68   - TAB: 9,
69   - ENTER: 13,
70   - ESCAPE: 27,
71   - SPACE: 32,
72   - PAGE_UP: 33,
73   - PAGE_DOWN: 34,
74   - END: 35,
75   - HOME: 36,
76   - LEFT: 37,
77   - UP: 38,
78   - RIGHT: 39,
79   - DOWN: 40,
80   - NUMPAD_ENTER: 108,
81   - COMMA: 188
82   -};
83   -
84   -// Additional public (exposed) methods
85   -var methods = {
86   - init: function(url_or_data_or_function, options) {
87   - return this.each(function () {
88   - $(this).data("tokenInputObject", new $.TokenList(this, url_or_data_or_function, options));
89   - });
90   - },
91   - clear: function() {
92   - this.data("tokenInputObject").clear();
93   - return this;
94   - },
95   - add: function(item) {
96   - this.data("tokenInputObject").add(item);
97   - return this;
98   - },
99   - remove: function(item) {
100   - this.data("tokenInputObject").remove(item);
101   - return this;
102   - }
103   -}
104   -
105   -// Expose the .tokenInput function to jQuery as a plugin
106   -$.fn.tokenInput = function (method) {
107   - // Method calling and initialization logic
108   - if(methods[method]) {
109   - return methods[method].apply(this, Array.prototype.slice.call(arguments, 1));
110   - } else {
111   - return methods.init.apply(this, arguments);
112   - }
113   -};
114   -
115   -// TokenList class for each input
116   -$.TokenList = function (input, url_or_data, options) {
117   - //
118   - // Initialization
119   - //
120   - var settings = $.extend({}, DEFAULT_SETTINGS, options || {});
121   -
122   - // Configure the data source
123   - if(typeof(url_or_data) === "string") {
124   - // Set the url to query against
125   - settings.url = url_or_data;
126   -
127   - // Make a smart guess about cross-domain if it wasn't explicitly specified
128   - if(settings.crossDomain === undefined) {
129   - if(settings.url.indexOf("://") === -1) {
130   - settings.crossDomain = false;
131   - } else {
132   - settings.crossDomain = (location.href.split(/\/+/g)[1] !== settings.url.split(/\/+/g)[1]);
133   - }
  71 +
  72 + // Keep track if the input is currently in disabled mode
  73 + disabled: false
  74 + };
  75 +
  76 + // Default classes to use when theming
  77 + var DEFAULT_CLASSES = {
  78 + tokenList : "token-input-list",
  79 + token : "token-input-token",
  80 + tokenReadOnly : "token-input-token-readonly",
  81 + tokenDelete : "token-input-delete-token",
  82 + selectedToken : "token-input-selected-token",
  83 + highlightedToken : "token-input-highlighted-token",
  84 + dropdown : "token-input-dropdown",
  85 + dropdownItem : "token-input-dropdown-item",
  86 + dropdownItem2 : "token-input-dropdown-item2",
  87 + selectedDropdownItem : "token-input-selected-dropdown-item",
  88 + inputToken : "token-input-input-token",
  89 + focused : "token-input-focused",
  90 + disabled : "token-input-disabled"
  91 + };
  92 +
  93 + // Input box position "enum"
  94 + var POSITION = {
  95 + BEFORE : 0,
  96 + AFTER : 1,
  97 + END : 2
  98 + };
  99 +
  100 + // Keys "enum"
  101 + var KEY = {
  102 + BACKSPACE : 8,
  103 + TAB : 9,
  104 + ENTER : 13,
  105 + ESCAPE : 27,
  106 + SPACE : 32,
  107 + PAGE_UP : 33,
  108 + PAGE_DOWN : 34,
  109 + END : 35,
  110 + HOME : 36,
  111 + LEFT : 37,
  112 + UP : 38,
  113 + RIGHT : 39,
  114 + DOWN : 40,
  115 + NUMPAD_ENTER : 108,
  116 + COMMA : 188
  117 + };
  118 +
  119 + var HTML_ESCAPES = {
  120 + '&' : '&amp;',
  121 + '<' : '&lt;',
  122 + '>' : '&gt;',
  123 + '"' : '&quot;',
  124 + "'" : '&#x27;',
  125 + '/' : '&#x2F;'
  126 + };
  127 +
  128 + var HTML_ESCAPE_CHARS = /[&<>"'\/]/g;
  129 +
  130 + function coerceToString(val) {
  131 + return String((val === null || val === undefined) ? '' : val);
  132 + }
  133 +
  134 + function _escapeHTML(text) {
  135 + return coerceToString(text).replace(HTML_ESCAPE_CHARS, function(match) {
  136 + return HTML_ESCAPES[match];
  137 + });
  138 + }
  139 +
  140 + // Additional public (exposed) methods
  141 + var methods = {
  142 + init: function(url_or_data_or_function, options) {
  143 + var settings = $.extend({}, DEFAULT_SETTINGS, options || {});
  144 +
  145 + return this.each(function () {
  146 + $(this).data("settings", settings);
  147 + $(this).data("tokenInputObject", new $.TokenList(this, url_or_data_or_function, settings));
  148 + });
  149 + },
  150 + clear: function() {
  151 + this.data("tokenInputObject").clear();
  152 + return this;
  153 + },
  154 + add: function(item) {
  155 + this.data("tokenInputObject").add(item);
  156 + return this;
  157 + },
  158 + remove: function(item) {
  159 + this.data("tokenInputObject").remove(item);
  160 + return this;
  161 + },
  162 + get: function() {
  163 + return this.data("tokenInputObject").getTokens();
  164 + },
  165 + toggleDisabled: function(disable) {
  166 + this.data("tokenInputObject").toggleDisabled(disable);
  167 + return this;
  168 + },
  169 + setOptions: function(options){
  170 + $(this).data("settings", $.extend({}, $(this).data("settings"), options || {}));
  171 + return this;
  172 + },
  173 + destroy: function () {
  174 + if (this.data("tokenInputObject")) {
  175 + this.data("tokenInputObject").clear();
  176 + var tmpInput = this;
  177 + var closest = this.parent();
  178 + closest.empty();
  179 + tmpInput.show();
  180 + closest.append(tmpInput);
  181 + return tmpInput;
134 182 }
135   - } else if(typeof(url_or_data) === "object") {
136   - // Set the local data to search through
137   - settings.local_data = url_or_data;
138   - }
139   -
140   - // Build class names
141   - if(settings.classes) {
142   - // Use custom class names
143   - settings.classes = $.extend({}, DEFAULT_CLASSES, settings.classes);
144   - } else if(settings.theme) {
145   - // Use theme-suffixed default class names
146   - settings.classes = {};
147   - $.each(DEFAULT_CLASSES, function(key, value) {
148   - settings.classes[key] = value + "-" + settings.theme;
149   - });
150   - } else {
151   - settings.classes = DEFAULT_CLASSES;
152   - }
153   -
154   -
155   - // Save the tokens
156   - var saved_tokens = [];
157   -
158   - // Keep track of the number of tokens in the list
159   - var token_count = 0;
160   -
161   - // Basic cache to save on db hits
162   - var cache = new $.TokenList.Cache();
163   -
164   - // Keep track of the timeout, old vals
165   - var timeout;
166   - var input_val = '';
167   -
168   - // Create a new text input an attach keyup events
169   - var input_box = $("<input type=\"text\" autocomplete=\"off\">")
170   - .css({
171   - outline: "none"
172   - })
173   - .attr("id", settings.idPrefix + input.id)
174   - .focus(function () {
175   - if (settings.tokenLimit === null || settings.tokenLimit !== token_count) {
176   - if(settings.permanentDropdown || settings.showAllResults) {
177   - hide_dropdown_hint();
  183 + }
  184 + };
  185 +
  186 + // Expose the .tokenInput function to jQuery as a plugin
  187 + $.fn.tokenInput = function (method) {
  188 + // Method calling and initialization logic
  189 + if (methods[method]) {
  190 + return methods[method].apply(this, Array.prototype.slice.call(arguments, 1));
  191 + } else {
  192 + return methods.init.apply(this, arguments);
  193 + }
  194 + };
  195 +
  196 + // TokenList class for each input
  197 + $.TokenList = function (input, url_or_data, settings) {
  198 + //
  199 + // Initialization
  200 + //
  201 +
  202 + // Configure the data source
  203 + if (typeof(url_or_data) === "string" || typeof(url_or_data) === "function") {
  204 + // Set the url to query against
  205 + $(input).data("settings").url = url_or_data;
  206 +
  207 + // If the URL is a function, evaluate it here to do our initalization work
  208 + var url = computeURL();
  209 +
  210 + // Make a smart guess about cross-domain if it wasn't explicitly specified
  211 + if ($(input).data("settings").crossDomain === undefined && typeof url === "string") {
  212 + if(url.indexOf("://") === -1) {
  213 + $(input).data("settings").crossDomain = false;
  214 + } else {
  215 + $(input).data("settings").crossDomain = (location.href.split(/\/+/g)[1] !== url.split(/\/+/g)[1]);
  216 + }
  217 + }
  218 + } else if (typeof(url_or_data) === "object") {
  219 + // Set the local data to search through
  220 + $(input).data("settings").local_data = url_or_data;
  221 + }
  222 +
  223 + // Build class names
  224 + if($(input).data("settings").classes) {
  225 + // Use custom class names
  226 + $(input).data("settings").classes = $.extend({}, DEFAULT_CLASSES, $(input).data("settings").classes);
  227 + } else if($(input).data("settings").theme) {
  228 + // Use theme-suffixed default class names
  229 + $(input).data("settings").classes = {};
  230 + $.each(DEFAULT_CLASSES, function(key, value) {
  231 + $(input).data("settings").classes[key] = value + "-" + $(input).data("settings").theme;
  232 + });
  233 + } else {
  234 + $(input).data("settings").classes = DEFAULT_CLASSES;
  235 + }
  236 +
  237 + // Save the tokens
  238 + var saved_tokens = [];
  239 +
  240 + // Keep track of the number of tokens in the list
  241 + var token_count = 0;
  242 +
  243 + // Basic cache to save on db hits
  244 + var cache = new $.TokenList.Cache();
  245 +
  246 + // Keep track of the timeout, old vals
  247 + var timeout;
  248 + var input_val;
  249 +
  250 + // Create a new text input an attach keyup events
  251 + var input_box = $("<input type=\"text\" autocomplete=\"off\" autocapitalize=\"off\"/>")
  252 + .css({
  253 + outline: "none"
  254 + })
  255 + .attr("id", $(input).data("settings").idPrefix + input.id)
  256 + .focus(function () {
  257 + if ($(input).data("settings").disabled) {
  258 + return false;
178 259 } else
179   - show_dropdown_hint();
180   - if (settings.showAllResults)
181   - do_search();
182   - }
183   - })
184   - .blur(function () {
185   - if(settings.permanentDropdown)
186   - show_dropdown_hint();
187   - else {
  260 + if ($(input).data("settings").tokenLimit === null || $(input).data("settings").tokenLimit !== token_count) {
  261 + show_dropdown_hint();
  262 + }
  263 + token_list.addClass($(input).data("settings").classes.focused);
  264 + })
  265 + .blur(function () {
188 266 hide_dropdown();
189   - }
190   - })
191   - .bind("keyup keydown blur update", resize_input)
192   - .keydown(function (event) {
193   - var previous_token;
194   - var next_token;
195   -
196   - switch(event.keyCode) {
197   - case KEY.LEFT:
198   - case KEY.RIGHT:
199   - case KEY.UP:
200   - case KEY.DOWN:
201   - if(!$(this).val()) {
  267 +
  268 + if ($(input).data("settings").allowFreeTagging) {
  269 + add_freetagging_tokens();
  270 + }
  271 +
  272 + $(this).val("");
  273 + token_list.removeClass($(input).data("settings").classes.focused);
  274 + })
  275 + .bind("keyup keydown blur update", resize_input)
  276 + .keydown(function (event) {
  277 + var previous_token;
  278 + var next_token;
  279 +
  280 + switch(event.keyCode) {
  281 + case KEY.LEFT:
  282 + case KEY.RIGHT:
  283 + case KEY.UP:
  284 + case KEY.DOWN:
  285 + if(this.value.length === 0) {
202 286 previous_token = input_token.prev();
203 287 next_token = input_token.next();
204 288  
205   - if((previous_token.length && previous_token.get(0) === selected_token) || (next_token.length && next_token.get(0) === selected_token)) {
  289 + if((previous_token.length && previous_token.get(0) === selected_token) ||
  290 + (next_token.length && next_token.get(0) === selected_token)) {
206 291 // Check if there is a previous/next token and it is selected
207 292 if(event.keyCode === KEY.LEFT || event.keyCode === KEY.UP) {
208 293 deselect_token($(selected_token), POSITION.BEFORE);
... ... @@ -217,650 +302,805 @@ $.TokenList = function (input, url_or_data, options) {
217 302 select_token($(next_token.get(0)));
218 303 }
219 304 } else {
220   - var dropdown_item = null;
221   -
222   - if (event.keyCode == KEY.LEFT && (this.selectionStart > 0 || this.selectionStart != this.selectionEnd))
223   - return true;
224   - else if (event.keyCode == KEY.RIGHT && (this.selectionEnd < $(this).val().length || this.selectionStart != this.selectionEnd))
225   - return true;
226   - else if(event.keyCode === KEY.DOWN || event.keyCode === KEY.RIGHT) {
227   - dropdown_item = $(selected_dropdown_item).next();
228   - } else {
229   - dropdown_item = $(selected_dropdown_item).prev();
  305 + var dropdown_item = null;
  306 +
  307 + if (event.keyCode === KEY.DOWN || event.keyCode === KEY.RIGHT) {
  308 + dropdown_item = $(dropdown).find('li').first();
  309 +
  310 + if (selected_dropdown_item) {
  311 + dropdown_item = $(selected_dropdown_item).next();
230 312 }
  313 + } else {
  314 + dropdown_item = $(dropdown).find('li').last();
231 315  
232   - if(dropdown_item.length) {
233   - select_dropdown_item(dropdown_item);
  316 + if (selected_dropdown_item) {
  317 + dropdown_item = $(selected_dropdown_item).prev();
234 318 }
235   - return false;
  319 + }
  320 +
  321 + select_dropdown_item(dropdown_item);
236 322 }
  323 +
237 324 break;
238 325  
239   - case KEY.BACKSPACE:
240   - case KEY.DELETE:
241   - previous_token = input_token.prev();
242   - next_token = input_token.next();
  326 + case KEY.BACKSPACE:
  327 + previous_token = input_token.prev();
243 328  
244   - if(!$(this).val().length && settings.backspaceDeleteItem) {
245   - if(selected_token) {
246   - delete_token($(selected_token));
247   - input_box.focus();
248   - } else if(KEY.DELETE && next_token.length) {
249   - select_token($(next_token.get(0)));
250   - } else if(KEY.BACKSPACE && previous_token.length) {
251   - select_token($(previous_token.get(0)));
  329 + if (this.value.length === 0) {
  330 + if (selected_token) {
  331 + delete_token($(selected_token));
  332 + hiddenInput.change();
  333 + } else if(previous_token.length) {
  334 + select_token($(previous_token.get(0)));
252 335 }
253 336  
254 337 return false;
255   - } else if(!settings.permanentDropdown && $(this).val().length === 1) {
256   - hide_dropdown();
  338 + } else if($(this).val().length === 1) {
  339 + hide_dropdown();
  340 + } else {
  341 + // set a timeout just long enough to let this function finish.
  342 + setTimeout(function(){ do_search(); }, 5);
  343 + }
  344 + break;
  345 +
  346 + case KEY.TAB:
  347 + case KEY.ENTER:
  348 + case KEY.NUMPAD_ENTER:
  349 + case KEY.COMMA:
  350 + if(selected_dropdown_item) {
  351 + add_token($(selected_dropdown_item).data("tokeninput"));
  352 + hiddenInput.change();
257 353 } else {
258   - // set a timeout just long enough to let this function finish.
259   - setTimeout(function(){do_search();}, 5);
  354 + if ($(input).data("settings").allowFreeTagging) {
  355 + if($(input).data("settings").allowTabOut && $(this).val() === "") {
  356 + return true;
  357 + } else {
  358 + add_freetagging_tokens();
  359 + }
  360 + } else {
  361 + $(this).val("");
  362 + if($(input).data("settings").allowTabOut) {
  363 + return true;
  364 + }
  365 + }
  366 + event.stopPropagation();
  367 + event.preventDefault();
260 368 }
261   - break;
262   -
263   - case KEY.TAB:
264   - case KEY.ENTER:
265   - case KEY.NUMPAD_ENTER:
266   - case KEY.COMMA:
267   - if(selected_dropdown_item) {
268   - add_token($(selected_dropdown_item).data("tokeninput"));
269   - input_box.focus();
270 369 return false;
271   - }
272   - break;
273 370  
274   - case KEY.ESCAPE:
275   - hide_dropdown();
276   - return true;
  371 + case KEY.ESCAPE:
  372 + hide_dropdown();
  373 + return true;
277 374  
278   - default:
279   - if(String.fromCharCode(event.which)) {
280   - // set a timeout just long enough to let this function finish.
281   - setTimeout(function(){do_search();}, 5);
  375 + default:
  376 + if (String.fromCharCode(event.which)) {
  377 + // set a timeout just long enough to let this function finish.
  378 + setTimeout(function(){ do_search(); }, 5);
282 379 }
283 380 break;
284   - }
285   - });
286   -
287   - // Keep a reference to the original input box
288   - var hidden_input = $(input)
289   - .hide()
290   - .val("")
291   - .focus(function () {
292   - input_box.focus();
293   - })
294   - .blur(function () {
295   - input_box.blur();
296   - });
297   -
298   - // Keep a reference to the selected token and dropdown item
299   - var selected_token = null;
300   - var selected_token_index = 0;
301   - var selected_dropdown_item = null;
302   -
303   - // The list to store the token items in
304   - var token_list = $("<ul />")
305   - .addClass(settings.classes.tokenList)
306   - .click(function (event) {
307   - var li = $(event.target).closest("li");
308   - if(li && li.get(0) && $.data(li.get(0), "tokeninput")) {
309   - toggle_select_token(li);
310   - } else {
311   - // Deselect selected token
312   - if(selected_token) {
313   - deselect_token($(selected_token), POSITION.END);
314   - }
315   -
316   - // Transfer focus
317   - if (!input_box.is(':focus'))
318   - input_box.focus();
319   - }
  381 + }
  382 + });
  383 +
  384 + // Keep reference for placeholder
  385 + if (settings.placeholder) {
  386 + input_box.attr("placeholder", settings.placeholder);
  387 + }
  388 +
  389 + // Keep a reference to the original input box
  390 + var hiddenInput = $(input)
  391 + .hide()
  392 + .val("")
  393 + .focus(function () {
  394 + focusWithTimeout(input_box);
320 395 })
321   - .mouseover(function (event) {
322   - var li = $(event.target).closest("li");
323   - if(li && selected_token !== this) {
324   - li.addClass(settings.classes.highlightedToken);
325   - }
  396 + .blur(function () {
  397 + input_box.blur();
  398 +
  399 + //return the object to this can be referenced in the callback functions.
  400 + return hiddenInput;
326 401 })
327   - .mouseout(function (event) {
328   - var li = $(event.target).closest("li");
329   - if(li && selected_token !== this) {
330   - li.removeClass(settings.classes.highlightedToken);
  402 + ;
  403 +
  404 + // Keep a reference to the selected token and dropdown item
  405 + var selected_token = null;
  406 + var selected_token_index = 0;
  407 + var selected_dropdown_item = null;
  408 +
  409 + // The list to store the token items in
  410 + var token_list = $("<ul />")
  411 + .addClass($(input).data("settings").classes.tokenList)
  412 + .click(function (event) {
  413 + var li = $(event.target).closest("li");
  414 + if(li && li.get(0) && $.data(li.get(0), "tokeninput")) {
  415 + toggle_select_token(li);
  416 + } else {
  417 + // Deselect selected token
  418 + if(selected_token) {
  419 + deselect_token($(selected_token), POSITION.END);
  420 + }
  421 +
  422 + // Focus input box
  423 + focusWithTimeout(input_box);
  424 + }
  425 + })
  426 + .mouseover(function (event) {
  427 + var li = $(event.target).closest("li");
  428 + if(li && selected_token !== this) {
  429 + li.addClass($(input).data("settings").classes.highlightedToken);
  430 + }
  431 + })
  432 + .mouseout(function (event) {
  433 + var li = $(event.target).closest("li");
  434 + if(li && selected_token !== this) {
  435 + li.removeClass($(input).data("settings").classes.highlightedToken);
  436 + }
  437 + })
  438 + .insertBefore(hiddenInput);
  439 +
  440 + // The token holding the input box
  441 + var input_token = $("<li />")
  442 + .addClass($(input).data("settings").classes.inputToken)
  443 + .appendTo(token_list)
  444 + .append(input_box);
  445 +
  446 + // The list to store the dropdown items in
  447 + var dropdown = $("<div/>")
  448 + .addClass($(input).data("settings").classes.dropdown)
  449 + .appendTo("body")
  450 + .hide();
  451 +
  452 + // Magic element to help us resize the text input
  453 + var input_resizer = $("<tester/>")
  454 + .insertAfter(input_box)
  455 + .css({
  456 + position: "absolute",
  457 + top: -9999,
  458 + left: -9999,
  459 + width: "auto",
  460 + fontSize: input_box.css("fontSize"),
  461 + fontFamily: input_box.css("fontFamily"),
  462 + fontWeight: input_box.css("fontWeight"),
  463 + letterSpacing: input_box.css("letterSpacing"),
  464 + whiteSpace: "nowrap"
  465 + });
  466 +
  467 + // Pre-populate list if items exist
  468 + hiddenInput.val("");
  469 + var li_data = $(input).data("settings").prePopulate || hiddenInput.data("pre");
  470 +
  471 + if ($(input).data("settings").processPrePopulate && $.isFunction($(input).data("settings").onResult)) {
  472 + li_data = $(input).data("settings").onResult.call(hiddenInput, li_data);
  473 + }
  474 +
  475 + if (li_data && li_data.length) {
  476 + $.each(li_data, function (index, value) {
  477 + insert_token(value);
  478 + checkTokenLimit();
  479 + input_box.attr("placeholder", null)
  480 + });
  481 + }
  482 +
  483 + // Check if widget should initialize as disabled
  484 + if ($(input).data("settings").disabled) {
  485 + toggleDisabled(true);
  486 + }
  487 +
  488 + // Initialization is done
  489 + if (typeof($(input).data("settings").onReady) === "function") {
  490 + $(input).data("settings").onReady.call();
  491 + }
  492 +
  493 + //
  494 + // Public functions
  495 + //
  496 +
  497 + this.clear = function() {
  498 + token_list.children("li").each(function() {
  499 + if ($(this).children("input").length === 0) {
  500 + delete_token($(this));
  501 + }
  502 + });
  503 + };
  504 +
  505 + this.add = function(item) {
  506 + add_token(item);
  507 + };
  508 +
  509 + this.remove = function(item) {
  510 + token_list.children("li").each(function() {
  511 + if ($(this).children("input").length === 0) {
  512 + var currToken = $(this).data("tokeninput");
  513 + var match = true;
  514 + for (var prop in item) {
  515 + if (item[prop] !== currToken[prop]) {
  516 + match = false;
  517 + break;
  518 + }
  519 + }
  520 + if (match) {
  521 + delete_token($(this));
  522 + }
  523 + }
  524 + });
  525 + };
  526 +
  527 + this.getTokens = function() {
  528 + return saved_tokens;
  529 + };
  530 +
  531 + this.toggleDisabled = function(disable) {
  532 + toggleDisabled(disable);
  533 + };
  534 +
  535 + // Resize input to maximum width so the placeholder can be seen
  536 + resize_input();
  537 +
  538 + //
  539 + // Private functions
  540 + //
  541 +
  542 + function escapeHTML(text) {
  543 + return $(input).data("settings").enableHTML ? text : _escapeHTML(text);
  544 + }
  545 +
  546 + // Toggles the widget between enabled and disabled state, or according
  547 + // to the [disable] parameter.
  548 + function toggleDisabled(disable) {
  549 + if (typeof disable === 'boolean') {
  550 + $(input).data("settings").disabled = disable
  551 + } else {
  552 + $(input).data("settings").disabled = !$(input).data("settings").disabled;
  553 + }
  554 + input_box.attr('disabled', $(input).data("settings").disabled);
  555 + token_list.toggleClass($(input).data("settings").classes.disabled, $(input).data("settings").disabled);
  556 + // if there is any token selected we deselect it
  557 + if(selected_token) {
  558 + deselect_token($(selected_token), POSITION.END);
  559 + }
  560 + hiddenInput.attr('disabled', $(input).data("settings").disabled);
  561 + }
  562 +
  563 + function checkTokenLimit() {
  564 + if($(input).data("settings").tokenLimit !== null && token_count >= $(input).data("settings").tokenLimit) {
  565 + input_box.hide();
  566 + hide_dropdown();
  567 + return;
  568 + }
  569 + }
  570 +
  571 + function resize_input() {
  572 + if(input_val === (input_val = input_box.val())) {return;}
  573 +
  574 + // Get width left on the current line
  575 + var width_left = token_list.width() - input_box.offset().left - token_list.offset().left;
  576 + // Enter new content into resizer and resize input accordingly
  577 + input_resizer.html(_escapeHTML(input_val) || _escapeHTML(settings.placeholder));
  578 + // Get maximum width, minimum the size of input and maximum the widget's width
  579 + input_box.width(Math.min(token_list.width(),
  580 + Math.max(width_left, input_resizer.width() + 30)));
  581 + }
  582 +
  583 + function add_freetagging_tokens() {
  584 + var value = $.trim(input_box.val());
  585 + var tokens = value.split($(input).data("settings").tokenDelimiter);
  586 + $.each(tokens, function(i, token) {
  587 + if (!token) {
  588 + return;
331 589 }
332   - })
333   - .insertBefore(hidden_input);
334   -
335   - // The token holding the input box
336   - var input_token = $("<li />")
337   - .addClass(settings.classes.inputToken)
338   - .appendTo(token_list)
339   - .append(input_box);
340   -
341   - // The list to store the dropdown items in
342   - var dropdown = $("<div>")
343   - .addClass(settings.classes.dropdown)
344   - .hide();
345   - dropdown.appendTo("body");
346   - if (!settings.permanentDropdown)
347   - dropdown.appendTo("body");
348   - else
349   - $(input).after(dropdown.show());
350   -
351   - if (settings.permanentDropdown || settings.showAllResults) {
352   - do_search();
353   - if (!settings.permanentDropdown && settings.showAllResults)
354   - hide_dropdown();
355   - }
356   -
357   - // Hint for permanentDropdown
358   - if (settings.permanentDropdown || settings.showAllResults)
359   - show_dropdown_hint();
360   -
361   - // Magic element to help us resize the text input
362   - var input_resizer = $("<tester/>")
363   - .insertAfter(input_box)
364   - .css({
365   - position: "absolute",
366   - top: -9999,
367   - left: -9999,
368   - width: "auto",
369   - fontSize: input_box.css("fontSize"),
370   - fontFamily: input_box.css("fontFamily"),
371   - fontWeight: input_box.css("fontWeight"),
372   - letterSpacing: input_box.css("letterSpacing"),
373   - whiteSpace: "nowrap"
374   - });
375   -
376   - // Pre-populate list if items exist
377   - hidden_input.val("");
378   - var li_data = settings.prePopulate || hidden_input.data("pre");
379   - if(settings.processPrePopulate && $.isFunction(settings.onResult)) {
380   - li_data = settings.onResult.call(hidden_input, li_data);
381   - }
382   - if(li_data && li_data.length) {
383   - $.each(li_data, function (index, value) {
384   - insert_token(value);
385   - checkTokenLimit({init: true});
386   - });
387   - }
388   -
389   -
390   - //
391   - // Public functions
392   - //
393   -
394   - this.clear = function() {
395   - token_list.children("li").each(function() {
396   - if ($(this).children("input").length === 0) {
397   - delete_token($(this));
  590 +
  591 + if ($.isFunction($(input).data("settings").onFreeTaggingAdd)) {
  592 + token = $(input).data("settings").onFreeTaggingAdd.call(hiddenInput, token);
398 593 }
399   - });
400   - }
401   -
402   - this.add = function(item) {
403   - add_token(item);
404   - }
405   -
406   - this.remove = function(item) {
407   - token_list.children("li").each(function() {
408   - if ($(this).children("input").length === 0) {
409   - var currToken = $(this).data("tokeninput");
410   - var match = true;
411   - for (var prop in item) {
412   - if (item[prop] !== currToken[prop]) {
413   - match = false;
414   - break;
  594 + var object = {};
  595 + object[$(input).data("settings").tokenValue] = object[$(input).data("settings").propertyToSearch] = token;
  596 + add_token(object);
  597 + });
  598 + }
  599 +
  600 + // Inner function to a token to the list
  601 + function insert_token(item) {
  602 + var $this_token = $($(input).data("settings").tokenFormatter(item));
  603 + var readonly = item.readonly === true;
  604 +
  605 + if(readonly) $this_token.addClass($(input).data("settings").classes.tokenReadOnly);
  606 +
  607 + $this_token.addClass($(input).data("settings").classes.token).insertBefore(input_token);
  608 +
  609 + // The 'delete token' button
  610 + if(!readonly) {
  611 + $("<span>" + $(input).data("settings").deleteText + "</span>")
  612 + .addClass($(input).data("settings").classes.tokenDelete)
  613 + .appendTo($this_token)
  614 + .click(function () {
  615 + if (!$(input).data("settings").disabled) {
  616 + delete_token($(this).parent());
  617 + hiddenInput.change();
  618 + return false;
415 619 }
416   - }
417   - if (match) {
418   - delete_token($(this));
419   - }
420   - }
421   - });
422   - }
423   -
424   - //
425   - // Private functions
426   - //
427   -
428   - function checkTokenLimit(options) {
429   - if(settings.tokenLimit !== null && token_count >= settings.tokenLimit) {
430   - input_box.hide();
431   - hide_dropdown();
432   - return;
433   - } else if (options && !options.init) {
434   - input_box.focus();
435   - }
436   - }
437   -
438   - function resize_input() {
439   - if(input_val === (input_val = input_box.val())) {return;}
440   -
441   - // Enter new content into resizer and resize input accordingly
442   - var escaped = input_val.replace(/&/g, '&amp;').replace(/\s/g,' ').replace(/</g, '&lt;').replace(/>/g, '&gt;');
443   - input_resizer.html(escaped);
444   - input_box.width(input_resizer.width() + 30);
445   -
446   - if((settings.permanentDropdown || settings.showAllResults) && input_box.hasClass(settings.classes.blurText))
447   - input_val = '';
448   - }
449   -
450   - function is_printable_character(keycode) {
451   - return ((keycode >= 48 && keycode <= 90) || // 0-1a-z
452   - (keycode >= 96 && keycode <= 111) || // numpad 0-9 + - / * .
453   - (keycode >= 186 && keycode <= 192) || // ; = , - . / ^
454   - (keycode >= 219 && keycode <= 222)); // ( \ ) '
455   - }
456   -
457   - // Inner function to a token to the list
458   - function insert_token(item) {
459   - var this_token = $("<li><p>"+ item.name +"</p></li>")
460   - .addClass(settings.classes.token)
461   - .insertBefore(input_token);
462   -
463   - // The 'delete token' button
464   - $("<span>" + settings.deleteText + "</span>")
465   - .addClass(settings.classes.tokenDelete)
466   - .appendTo(this_token)
467   - .click(function () {
468   - delete_token($(this).parent());
469   - return false;
470   - });
471   -
472   - // Store data on the token
473   - var token_data = {"id": item.id, "name": item.name};
474   - $.data(this_token.get(0), "tokeninput", item);
475   -
476   - // Save this token for duplicate checking
477   - saved_tokens = saved_tokens.slice(0,selected_token_index).concat([token_data]).concat(saved_tokens.slice(selected_token_index));
478   - selected_token_index++;
479   -
480   - // Update the hidden input
481   - var token_ids = $.map(saved_tokens, function (el) {
482   - return el.id;
483   - });
484   - hidden_input.val(token_ids.join(settings.tokenDelimiter));
485   -
486   - token_count += 1;
487   -
488   - return this_token;
489   - }
490   -
491   - // Add a token to the token list based on user input
492   - function add_token (item) {
493   - if (settings.dontAdd)
494   - return;
495   -
496   - var callback = settings.onAdd;
497   -
498   - // See if the token already exists and select it if we don't want duplicates
499   - if(token_count > 0 && settings.preventDuplicates) {
500   - var found_existing_token = null;
501   - token_list.children().each(function () {
502   - var existing_token = $(this);
503   - var existing_data = $.data(existing_token.get(0), "tokeninput");
504   - if(existing_data && existing_data.id === item.id) {
505   - found_existing_token = existing_token;
506   - return false;
507   - }
508   - });
  620 + });
  621 + }
509 622  
510   - if(found_existing_token) {
511   - select_token(found_existing_token);
512   - input_token.insertAfter(found_existing_token);
513   - return;
514   - }
515   - }
  623 + // Store data on the token
  624 + var token_data = item;
  625 + $.data($this_token.get(0), "tokeninput", item);
516 626  
517   - // Insert the new tokens
518   - insert_token(item);
519   - checkTokenLimit();
  627 + // Save this token for duplicate checking
  628 + saved_tokens = saved_tokens.slice(0,selected_token_index).concat([token_data]).concat(saved_tokens.slice(selected_token_index));
  629 + selected_token_index++;
520 630  
521   - // Clear input box
522   - input_box.val("");
  631 + // Update the hidden input
  632 + update_hiddenInput(saved_tokens, hiddenInput);
523 633  
524   - // Don't show the help dropdown, they've got the idea
525   - hide_dropdown();
  634 + token_count += 1;
526 635  
527   - // Execute the onAdd callback if defined
528   - if($.isFunction(callback)) {
529   - callback.call(hidden_input,item);
530   - }
531   - }
532   -
533   - // Select a token in the token list
534   - function select_token (token) {
535   - token.addClass(settings.classes.selectedToken);
536   - selected_token = token.get(0);
537   -
538   - // Hide input box
539   - input_box.val("");
540   -
541   - // Hide dropdown if it is visible (eg if we clicked to select token)
542   - hide_dropdown();
543   - }
544   -
545   - // Deselect a token in the token list
546   - function deselect_token (token, position) {
547   - token.removeClass(settings.classes.selectedToken);
548   - selected_token = null;
549   -
550   - if(position === POSITION.BEFORE) {
551   - input_token.insertBefore(token);
552   - selected_token_index--;
553   - } else if(position === POSITION.AFTER) {
554   - input_token.insertAfter(token);
555   - selected_token_index++;
556   - } else {
557   - input_token.appendTo(token_list);
558   - selected_token_index = token_count;
559   - }
  636 + // Check the token limit
  637 + if($(input).data("settings").tokenLimit !== null && token_count >= $(input).data("settings").tokenLimit) {
  638 + input_box.hide();
  639 + hide_dropdown();
  640 + }
560 641  
561   - // Show the input box and give it focus again
562   - input_box.focus();
563   - }
  642 + return $this_token;
  643 + }
564 644  
565   - // Toggle selection of a token in the token list
566   - function toggle_select_token(token) {
567   - var previous_selected_token = selected_token;
  645 + // Add a token to the token list based on user input
  646 + function add_token (item) {
  647 + var callback = $(input).data("settings").onAdd;
  648 +
  649 + // See if the token already exists and select it if we don't want duplicates
  650 + if(token_count > 0 && $(input).data("settings").preventDuplicates) {
  651 + var found_existing_token = null;
  652 + token_list.children().each(function () {
  653 + var existing_token = $(this);
  654 + var existing_data = $.data(existing_token.get(0), "tokeninput");
  655 + if(existing_data && existing_data[settings.tokenValue] === item[settings.tokenValue]) {
  656 + found_existing_token = existing_token;
  657 + return false;
  658 + }
  659 + });
  660 +
  661 + if(found_existing_token) {
  662 + select_token(found_existing_token);
  663 + input_token.insertAfter(found_existing_token);
  664 + focusWithTimeout(input_box);
  665 + return;
  666 + }
  667 + }
568 668  
569   - if(selected_token) {
570   - deselect_token($(selected_token), POSITION.END);
571   - }
  669 + // Squeeze input_box so we force no unnecessary line break
  670 + input_box.width(1);
572 671  
573   - if(previous_selected_token === token.get(0)) {
574   - deselect_token(token, POSITION.END);
575   - } else {
576   - select_token(token);
577   - }
578   - }
  672 + // Insert the new tokens
  673 + if($(input).data("settings").tokenLimit == null || token_count < $(input).data("settings").tokenLimit) {
  674 + insert_token(item);
  675 + // Remove the placeholder so it's not seen after you've added a token
  676 + input_box.attr("placeholder", null);
  677 + checkTokenLimit();
  678 + }
  679 +
  680 + // Clear input box
  681 + input_box.val("");
579 682  
580   - // Delete a token from the token list
581   - function delete_token (token) {
582   - // Remove the id from the saved list
583   - var token_data = $.data(token.get(0), "tokeninput");
584   - var callback = settings.onDelete;
  683 + // Don't show the help dropdown, they've got the idea
  684 + hide_dropdown();
585 685  
586   - var index = token.prevAll().length;
587   - if(index > selected_token_index) index--;
  686 + // Execute the onAdd callback if defined
  687 + if($.isFunction(callback)) {
  688 + callback.call(hiddenInput,item);
  689 + }
  690 + }
588 691  
589   - // Delete the token
590   - token.remove();
591   - selected_token = null;
  692 + // Select a token in the token list
  693 + function select_token (token) {
  694 + if (!$(input).data("settings").disabled) {
  695 + token.addClass($(input).data("settings").classes.selectedToken);
  696 + selected_token = token.get(0);
592 697  
593   - // Remove this token from the saved list
594   - saved_tokens = saved_tokens.slice(0,index).concat(saved_tokens.slice(index+1));
595   - if(index < selected_token_index) selected_token_index--;
  698 + // Hide input box
  699 + input_box.val("");
596 700  
597   - // Update the hidden input
598   - var token_ids = $.map(saved_tokens, function (el) {
599   - return el.id;
600   - });
601   - hidden_input.val(token_ids.join(settings.tokenDelimiter));
  701 + // Hide dropdown if it is visible (eg if we clicked to select token)
  702 + hide_dropdown();
  703 + }
  704 + }
602 705  
603   - token_count -= 1;
  706 + // Deselect a token in the token list
  707 + function deselect_token (token, position) {
  708 + token.removeClass($(input).data("settings").classes.selectedToken);
  709 + selected_token = null;
  710 +
  711 + if(position === POSITION.BEFORE) {
  712 + input_token.insertBefore(token);
  713 + selected_token_index--;
  714 + } else if(position === POSITION.AFTER) {
  715 + input_token.insertAfter(token);
  716 + selected_token_index++;
  717 + } else {
  718 + input_token.appendTo(token_list);
  719 + selected_token_index = token_count;
  720 + }
604 721  
605   - if(settings.tokenLimit !== null)
606   - input_box.show().val("");
  722 + // Show the input box and give it focus again
  723 + focusWithTimeout(input_box);
  724 + }
607 725  
608   - // Execute the onDelete callback if defined
609   - if($.isFunction(callback)) {
610   - callback.call(hidden_input,token_data);
611   - }
612   - }
  726 + // Toggle selection of a token in the token list
  727 + function toggle_select_token(token) {
  728 + var previous_selected_token = selected_token;
613 729  
614   - // Hide and clear the results dropdown
615   - function hide_dropdown () {
616   - if (!settings.permanentDropdown) {
617   - dropdown.hide();
618   - if (!settings.showAllResults)
619   - dropdown.empty();
620   - selected_dropdown_item = null;
621   - }
622   - if (settings.showAllResults)
623   - show_dropdown_hint();
624   - }
625   -
626   - function show_dropdown() {
627   - if (!settings.permanentDropdown)
628   - dropdown.css({
629   - position: "absolute",
630   - top: $(token_list).offset().top + $(token_list).outerHeight(),
631   - left: $(token_list).offset().left,
632   - 'z-index': settings.zindex
633   - }).show();
634   - else
635   - dropdown.css({
636   - position: "relative",
637   - }).show();
638   - }
639   -
640   - function show_dropdown_searching () {
641   - if(settings.searchingText) {
642   - dropdown.html("<p>"+settings.searchingText+"</p>");
643   - show_dropdown();
644   - }
645   - }
646   -
647   - function show_dropdown_hint () {
648   - if(settings.hintText) {
649   - if(settings.permanentDropdown || settings.showAllResults) {
650   - if (input_val == '') {
651   - input_box.val(settings.hintText);
652   - input_box.addClass(settings.classes.blurText);
653   - }
  730 + if(selected_token) {
  731 + deselect_token($(selected_token), POSITION.END);
  732 + }
  733 +
  734 + if(previous_selected_token === token.get(0)) {
  735 + deselect_token(token, POSITION.END);
654 736 } else {
655   - dropdown.html("<p>"+settings.hintText+"</p>");
656   - show_dropdown();
  737 + select_token(token);
657 738 }
658   - }
659   - }
660   -
661   - function hide_dropdown_hint () {
662   - if (input_box.hasClass(settings.classes.blurText)) {
663   - input_box.val('');
664   - input_box.removeClass(settings.classes.blurText);
665   - }
666   - }
667   -
668   - // Highlight the query part of the search term
669   - function highlight_term(value, term) {
670   - return value.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)(" + term + ")(?![^<>]*>)(?![^&;]+;)", "gi"), "<b>$1</b>");
671   - }
672   -
673   - // Populate the results dropdown with some results
674   - function populate_dropdown (query, results) {
675   - if(results && results.length) {
676   - dropdown.empty();
677   - var dropdown_ul = $("<ul>")
678   - .appendTo(dropdown)
679   - .mouseover(function (event) {
680   - select_dropdown_item($(event.target).closest("li"));
681   - })
682   - .mousedown(function (event) {
683   - add_token($(event.target).closest("li").data("tokeninput"));
684   - input_box.blur();
685   - return false;
686   - })
687   - .hide();
  739 + }
688 740  
689   - $.each(results, function(index, value) {
690   - var this_li = $("<li>" + highlight_term(value.name, query) + "</li>")
691   - .appendTo(dropdown_ul);
  741 + // Delete a token from the token list
  742 + function delete_token (token) {
  743 + // Remove the id from the saved list
  744 + var token_data = $.data(token.get(0), "tokeninput");
  745 + var callback = $(input).data("settings").onDelete;
692 746  
693   - if(index % 2) {
694   - this_li.addClass(settings.classes.dropdownItem);
695   - } else {
696   - this_li.addClass(settings.classes.dropdownItem2);
697   - }
  747 + var index = token.prevAll().length;
  748 + if(index > selected_token_index) index--;
698 749  
699   - if(index === 0) {
700   - select_dropdown_item(this_li);
701   - }
  750 + // Delete the token
  751 + token.remove();
  752 + selected_token = null;
702 753  
703   - $.data(this_li.get(0), "tokeninput", value);
704   - });
  754 + // Show the input box and give it focus again
  755 + focusWithTimeout(input_box);
705 756  
706   - show_dropdown();
  757 + // Remove this token from the saved list
  758 + saved_tokens = saved_tokens.slice(0,index).concat(saved_tokens.slice(index+1));
  759 + if (saved_tokens.length == 0) {
  760 + input_box.attr("placeholder", settings.placeholder)
  761 + }
  762 + if(index < selected_token_index) selected_token_index--;
707 763  
708   - if(settings.animateDropdown) {
709   - dropdown_ul.slideDown("fast");
710   - } else {
711   - dropdown_ul.show();
712   - }
713   - } else {
714   - if(settings.noResultsText) {
715   - dropdown.html("<p>"+settings.noResultsText+"</p>");
716   - show_dropdown();
717   - }
718   - }
719   - }
  764 + // Update the hidden input
  765 + update_hiddenInput(saved_tokens, hiddenInput);
720 766  
721   - // Highlight an item in the results dropdown
722   - function select_dropdown_item (item) {
723   - if(item) {
724   - if(selected_dropdown_item) {
725   - deselect_dropdown_item($(selected_dropdown_item));
726   - }
  767 + token_count -= 1;
727 768  
728   - item.addClass(settings.classes.selectedDropdownItem);
729   - selected_dropdown_item = item.get(0);
730   -
731   - isBefore = item[0].offsetTop <= (dropdown[0].scrollTop + dropdown[0].scrollWidth);
732   - isAfter = item[0].offsetTop >= dropdown[0].scrollTop;
733   - visible = isBefore && isAfter;
734   - if (!visible) {
735   - if (isBefore)
736   - dropdown[0].scrollTop = item[0].offsetTop;
737   - else //isAfter
738   - dropdown[0].scrollTop = item[0].offsetTop - dropdown[0].scrollWidth;
739   - }
740   - }
741   - }
742   -
743   - // Remove highlighting from an item in the results dropdown
744   - function deselect_dropdown_item (item) {
745   - item.removeClass(settings.classes.selectedDropdownItem);
746   - selected_dropdown_item = null;
747   - }
748   -
749   - // Do a search and show the "searching" dropdown if the input is longer
750   - // than settings.minChars
751   - function do_search() {
752   - var query = input_box.val().toLowerCase();
753   -
754   - if(query && query.length) {
755   - if(selected_token) {
756   - deselect_token($(selected_token), POSITION.AFTER);
757   - }
  769 + if($(input).data("settings").tokenLimit !== null) {
  770 + input_box
  771 + .show()
  772 + .val("");
  773 + focusWithTimeout(input_box);
  774 + }
  775 +
  776 + // Execute the onDelete callback if defined
  777 + if($.isFunction(callback)) {
  778 + callback.call(hiddenInput,token_data);
  779 + }
  780 + }
758 781  
759   - if(query.length >= settings.minChars) {
760   - show_dropdown_searching();
761   - clearTimeout(timeout);
  782 + // Update the hidden input box value
  783 + function update_hiddenInput(saved_tokens, hiddenInput) {
  784 + var token_values = $.map(saved_tokens, function (el) {
  785 + if(typeof $(input).data("settings").tokenValue == 'function')
  786 + return $(input).data("settings").tokenValue.call(this, el);
  787 +
  788 + return el[$(input).data("settings").tokenValue];
  789 + });
  790 + hiddenInput.val(token_values.join($(input).data("settings").tokenDelimiter));
  791 +
  792 + }
  793 +
  794 + // Hide and clear the results dropdown
  795 + function hide_dropdown () {
  796 + dropdown.hide().empty();
  797 + selected_dropdown_item = null;
  798 + }
  799 +
  800 + function show_dropdown() {
  801 + dropdown
  802 + .css({
  803 + position: "absolute",
  804 + top: token_list.offset().top + token_list.outerHeight(true),
  805 + left: token_list.offset().left,
  806 + width: token_list.width(),
  807 + 'z-index': $(input).data("settings").zindex
  808 + })
  809 + .show();
  810 + }
  811 +
  812 + function show_dropdown_searching () {
  813 + if($(input).data("settings").searchingText) {
  814 + dropdown.html("<p>" + escapeHTML($(input).data("settings").searchingText) + "</p>");
  815 + show_dropdown();
  816 + }
  817 + }
  818 +
  819 + function show_dropdown_hint () {
  820 + if($(input).data("settings").hintText) {
  821 + dropdown.html("<p>" + escapeHTML($(input).data("settings").hintText) + "</p>");
  822 + show_dropdown();
  823 + }
  824 + }
  825 +
  826 + var regexp_special_chars = new RegExp('[.\\\\+*?\\[\\^\\]$(){}=!<>|:\\-]', 'g');
  827 + function regexp_escape(term) {
  828 + return term.replace(regexp_special_chars, '\\$&');
  829 + }
762 830  
763   - timeout = setTimeout(function(){
764   - run_search(query);
765   - }, settings.searchDelay);
766   - } else {
767   - hide_dropdown();
  831 + // Highlight the query part of the search term
  832 + function highlight_term(value, term) {
  833 + return value.replace(
  834 + new RegExp(
  835 + "(?![^&;]+;)(?!<[^<>]*)(" + regexp_escape(term) + ")(?![^<>]*>)(?![^&;]+;)",
  836 + "gi"
  837 + ), function(match, p1) {
  838 + return "<b>" + escapeHTML(p1) + "</b>";
768 839 }
769   - } else if (settings.permanentDropdown || settings.showAllResults)
770   - run_search('');
771   - }
772   -
773   - // Do the actual search
774   - function run_search(query) {
775   - var cached_results = cache.get(query);
776   - if(cached_results) {
777   - populate_dropdown(query, cached_results);
778   - } else {
779   - // Are we doing an ajax search or local data search?
780   - if(settings.url) {
781   - // Extract exisiting get params
782   - var ajax_params = {};
783   - ajax_params.data = {};
784   - if(settings.url.indexOf("?") > -1) {
785   - var parts = settings.url.split("?");
786   - ajax_params.url = parts[0];
787   -
788   - var param_array = parts[1].split("&");
789   - $.each(param_array, function (index, value) {
790   - var kv = value.split("=");
791   - ajax_params.data[kv[0]] = kv[1];
792   - });
793   - } else {
794   - ajax_params.url = settings.url;
795   - }
796   -
797   - // Prepare the request
798   - ajax_params.data[settings.queryParam] = query;
799   - ajax_params.type = settings.method;
800   - ajax_params.dataType = settings.contentType;
801   - if(settings.crossDomain) {
802   - ajax_params.dataType = "jsonp";
803   - }
804   -
805   - // Attach the success callback
806   - ajax_params.success = function(results) {
807   - if($.isFunction(settings.onResult)) {
808   - results = settings.onResult.call(hidden_input, results);
  840 + );
  841 + }
  842 +
  843 + function find_value_and_highlight_term(template, value, term) {
  844 + return template.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)(" + regexp_escape(value) + ")(?![^<>]*>)(?![^&;]+;)", "g"), highlight_term(value, term));
  845 + }
  846 +
  847 + // exclude existing tokens from dropdown, so the list is clearer
  848 + function excludeCurrent(results) {
  849 + if ($(input).data("settings").excludeCurrent) {
  850 + var currentTokens = $(input).data("tokenInputObject").getTokens(),
  851 + trimmedList = [];
  852 + if (currentTokens.length) {
  853 + $.each(results, function(index, value) {
  854 + var notFound = true;
  855 + $.each(currentTokens, function(cIndex, cValue) {
  856 + if (value[$(input).data("settings").propertyToSearch] == cValue[$(input).data("settings").propertyToSearch]) {
  857 + notFound = false;
  858 + return false;
  859 + }
  860 + });
  861 +
  862 + if (notFound) {
  863 + trimmedList.push(value);
  864 + }
  865 + });
  866 + results = trimmedList;
  867 + }
  868 + }
  869 +
  870 + return results;
  871 + }
  872 +
  873 + // Populate the results dropdown with some results
  874 + function populateDropdown (query, results) {
  875 + // exclude current tokens if configured
  876 + results = excludeCurrent(results);
  877 +
  878 + if(results && results.length) {
  879 + dropdown.empty();
  880 + var dropdown_ul = $("<ul/>")
  881 + .appendTo(dropdown)
  882 + .mouseover(function (event) {
  883 + select_dropdown_item($(event.target).closest("li"));
  884 + })
  885 + .mousedown(function (event) {
  886 + add_token($(event.target).closest("li").data("tokeninput"));
  887 + hiddenInput.change();
  888 + return false;
  889 + })
  890 + .hide();
  891 +
  892 + if ($(input).data("settings").resultsLimit && results.length > $(input).data("settings").resultsLimit) {
  893 + results = results.slice(0, $(input).data("settings").resultsLimit);
  894 + }
  895 +
  896 + $.each(results, function(index, value) {
  897 + var this_li = $(input).data("settings").resultsFormatter(value);
  898 +
  899 + this_li = find_value_and_highlight_term(this_li ,value[$(input).data("settings").propertyToSearch], query);
  900 + this_li = $(this_li).appendTo(dropdown_ul);
  901 +
  902 + if(index % 2) {
  903 + this_li.addClass($(input).data("settings").classes.dropdownItem);
  904 + } else {
  905 + this_li.addClass($(input).data("settings").classes.dropdownItem2);
809 906 }
810   - cache.add(query, settings.jsonContainer ? results[settings.jsonContainer] : results);
811 907  
812   - // only populate the dropdown if the results are associated with the active search query
813   - if(input_box.val().toLowerCase() === query) {
814   - populate_dropdown(query, settings.jsonContainer ? results[settings.jsonContainer] : results);
  908 + if(index === 0 && $(input).data("settings").autoSelectFirstResult) {
  909 + select_dropdown_item(this_li);
815 910 }
816   - };
817   -
818   - // Make the request
819   - $.ajax(ajax_params);
820   - } else if(settings.local_data) {
821   - // Do the search through local data
822   - var results = $.grep(settings.local_data, function (row) {
823   - return row.name.toLowerCase().indexOf(query.toLowerCase()) > -1;
824   - });
825 911  
826   - if($.isFunction(settings.onResult)) {
827   - results = settings.onResult.call(hidden_input, results);
828   - }
829   - cache.add(query, results);
830   - populate_dropdown(query, results);
831   - }
832   - }
833   - }
834   -};
  912 + $.data(this_li.get(0), "tokeninput", value);
  913 + });
  914 +
  915 + show_dropdown();
  916 +
  917 + if($(input).data("settings").animateDropdown) {
  918 + dropdown_ul.slideDown("fast");
  919 + } else {
  920 + dropdown_ul.show();
  921 + }
  922 + } else {
  923 + if($(input).data("settings").noResultsText) {
  924 + dropdown.html("<p>" + escapeHTML($(input).data("settings").noResultsText) + "</p>");
  925 + show_dropdown();
  926 + }
  927 + }
  928 + }
  929 +
  930 + // Highlight an item in the results dropdown
  931 + function select_dropdown_item (item) {
  932 + if(item) {
  933 + if(selected_dropdown_item) {
  934 + deselect_dropdown_item($(selected_dropdown_item));
  935 + }
835 936  
836   -// Really basic cache for the results
837   -$.TokenList.Cache = function (options) {
838   - var settings = $.extend({
839   - max_size: 500
840   - }, options);
  937 + item.addClass($(input).data("settings").classes.selectedDropdownItem);
  938 + selected_dropdown_item = item.get(0);
  939 + }
  940 + }
  941 +
  942 + // Remove highlighting from an item in the results dropdown
  943 + function deselect_dropdown_item (item) {
  944 + item.removeClass($(input).data("settings").classes.selectedDropdownItem);
  945 + selected_dropdown_item = null;
  946 + }
841 947  
842   - var data = {};
843   - var size = 0;
  948 + // Do a search and show the "searching" dropdown if the input is longer
  949 + // than $(input).data("settings").minChars
  950 + function do_search() {
  951 + var query = input_box.val();
844 952  
845   - var flush = function () {
846   - data = {};
847   - size = 0;
  953 + if(query && query.length) {
  954 + if(selected_token) {
  955 + deselect_token($(selected_token), POSITION.AFTER);
  956 + }
  957 +
  958 + if(query.length >= $(input).data("settings").minChars) {
  959 + show_dropdown_searching();
  960 + clearTimeout(timeout);
  961 +
  962 + timeout = setTimeout(function(){
  963 + run_search(query);
  964 + }, $(input).data("settings").searchDelay);
  965 + } else {
  966 + hide_dropdown();
  967 + }
  968 + }
  969 + }
  970 +
  971 + // Do the actual search
  972 + function run_search(query) {
  973 + var cache_key = query + computeURL();
  974 + var cached_results = cache.get(cache_key);
  975 + if (cached_results) {
  976 + if ($.isFunction($(input).data("settings").onCachedResult)) {
  977 + cached_results = $(input).data("settings").onCachedResult.call(hiddenInput, cached_results);
  978 + }
  979 + populateDropdown(query, cached_results);
  980 + } else {
  981 + // Are we doing an ajax search or local data search?
  982 + if($(input).data("settings").url) {
  983 + var url = computeURL();
  984 + // Extract existing get params
  985 + var ajax_params = {};
  986 + ajax_params.data = {};
  987 + if(url.indexOf("?") > -1) {
  988 + var parts = url.split("?");
  989 + ajax_params.url = parts[0];
  990 +
  991 + var param_array = parts[1].split("&");
  992 + $.each(param_array, function (index, value) {
  993 + var kv = value.split("=");
  994 + ajax_params.data[kv[0]] = kv[1];
  995 + });
  996 + } else {
  997 + ajax_params.url = url;
  998 + }
  999 +
  1000 + // Prepare the request
  1001 + ajax_params.data[$(input).data("settings").queryParam] = query;
  1002 + ajax_params.type = $(input).data("settings").method;
  1003 + ajax_params.dataType = $(input).data("settings").contentType;
  1004 + if ($(input).data("settings").crossDomain) {
  1005 + ajax_params.dataType = "jsonp";
  1006 + }
  1007 +
  1008 + // exclude current tokens?
  1009 + // send exclude list to the server, so it can also exclude existing tokens
  1010 + if ($(input).data("settings").excludeCurrent) {
  1011 + var currentTokens = $(input).data("tokenInputObject").getTokens();
  1012 + var tokenList = $.map(currentTokens, function (el) {
  1013 + if(typeof $(input).data("settings").tokenValue == 'function')
  1014 + return $(input).data("settings").tokenValue.call(this, el);
  1015 +
  1016 + return el[$(input).data("settings").tokenValue];
  1017 + });
  1018 +
  1019 + ajax_params.data[$(input).data("settings").excludeCurrentParameter] = tokenList.join($(input).data("settings").tokenDelimiter);
  1020 + }
  1021 +
  1022 + // Attach the success callback
  1023 + ajax_params.success = function(results) {
  1024 + cache.add(cache_key, $(input).data("settings").jsonContainer ? results[$(input).data("settings").jsonContainer] : results);
  1025 + if($.isFunction($(input).data("settings").onResult)) {
  1026 + results = $(input).data("settings").onResult.call(hiddenInput, results);
  1027 + }
  1028 +
  1029 + // only populate the dropdown if the results are associated with the active search query
  1030 + if(input_box.val() === query) {
  1031 + populateDropdown(query, $(input).data("settings").jsonContainer ? results[$(input).data("settings").jsonContainer] : results);
  1032 + }
  1033 + };
  1034 +
  1035 + // Provide a beforeSend callback
  1036 + if (settings.onSend) {
  1037 + settings.onSend(ajax_params);
  1038 + }
  1039 +
  1040 + // Make the request
  1041 + $.ajax(ajax_params);
  1042 + } else if($(input).data("settings").local_data) {
  1043 + // Do the search through local data
  1044 + var results = $.grep($(input).data("settings").local_data, function (row) {
  1045 + return row[$(input).data("settings").propertyToSearch].toLowerCase().indexOf(query.toLowerCase()) > -1;
  1046 + });
  1047 +
  1048 + cache.add(cache_key, results);
  1049 + if($.isFunction($(input).data("settings").onResult)) {
  1050 + results = $(input).data("settings").onResult.call(hiddenInput, results);
  1051 + }
  1052 + populateDropdown(query, results);
  1053 + }
  1054 + }
  1055 + }
  1056 +
  1057 + // compute the dynamic URL
  1058 + function computeURL() {
  1059 + var settings = $(input).data("settings");
  1060 + return typeof settings.url == 'function' ? settings.url.call(settings) : settings.url;
  1061 + }
  1062 +
  1063 + // Bring browser focus to the specified object.
  1064 + // Use of setTimeout is to get around an IE bug.
  1065 + // (See, e.g., http://stackoverflow.com/questions/2600186/focus-doesnt-work-in-ie)
  1066 + //
  1067 + // obj: a jQuery object to focus()
  1068 + function focusWithTimeout(object) {
  1069 + setTimeout(
  1070 + function() {
  1071 + object.focus();
  1072 + },
  1073 + 50
  1074 + );
  1075 + }
  1076 + };
  1077 +
  1078 + // Really basic cache for the results
  1079 + $.TokenList.Cache = function (options) {
  1080 + var settings, data = {}, size = 0, flush;
  1081 +
  1082 + settings = $.extend({ max_size: 500 }, options);
  1083 +
  1084 + flush = function () {
  1085 + data = {};
  1086 + size = 0;
848 1087 };
849 1088  
850 1089 this.add = function (query, results) {
851   - if(size > settings.max_size) {
852   - flush();
853   - }
  1090 + if (size > settings.max_size) {
  1091 + flush();
  1092 + }
854 1093  
855   - if(!data[query]) {
856   - size += 1;
857   - }
  1094 + if (!data[query]) {
  1095 + size += 1;
  1096 + }
858 1097  
859   - data[query] = results;
  1098 + data[query] = results;
860 1099 };
861 1100  
862 1101 this.get = function (query) {
863   - return data[query];
  1102 + return data[query];
864 1103 };
865   -};
  1104 + };
  1105 +
866 1106 }(jQuery));
... ...
public/stylesheets/profile-activity.scss
... ... @@ -3,7 +3,8 @@
3 3 padding-left: 0;
4 4 clear: both;
5 5 }
6   -#profile-activity li, #profile-network li, #profile-wall li {
  6 +
  7 +.profile-activities li {
7 8 display: block;
8 9 padding: 3px 0px;
9 10 margin-bottom: 3px;
... ... @@ -367,6 +368,9 @@ li.profile-activity-item.upload_image .activity-gallery-images-count-1 img {
367 368 .profile-wall-message {
368 369 margin: 0;
369 370 }
  371 +.limited-text-area {
  372 + margin-bottom: 15px;
  373 +}
370 374 .limited-text-area p {
371 375 margin: 0;
372 376 font-size: 11px;
... ... @@ -378,7 +382,8 @@ li.profile-activity-item.upload_image .activity-gallery-images-count-1 img {
378 382 margin-bottom: 10px;
379 383 }
380 384 #leave_scrap_content_limit, #leave_scrap_content_left {
381   - float: left;
  385 + float: right;
  386 + margin-right: 2px;
382 387 }
383 388 #leave_scrap {
384 389 float: left;
... ... @@ -392,6 +397,9 @@ li.profile-activity-item.upload_image .activity-gallery-images-count-1 img {
392 397 #leave_scrap .loading textarea {
393 398 background: url('/images/loading-small.gif') 50% 50% no-repeat;
394 399 }
  400 +#leave_scrap .submit {
  401 + margin-top: 5px;
  402 +}
395 403 .profile-send-reply {
396 404 color: #aaa;
397 405 }
... ... @@ -598,7 +606,6 @@ li.profile-activity-item.upload_image .activity-gallery-images-count-1 img {
598 606 }
599 607  
600 608 #profile-wall #leave_scrap textarea {
601   - width: 442px;
602 609 height: 100px
603 610 }
604 611 .profile-wall-scrap-replies {
... ...
public/stylesheets/vendor/token-input-facebook.css
... ... @@ -7,7 +7,7 @@ ul.token-input-list-facebook {
7 7 border: 1px solid #8496ba;
8 8 cursor: text;
9 9 font-size: 12px;
10   - font-family: Verdana;
  10 + font-family: Verdana, sans-serif;
11 11 min-height: 1px;
12 12 z-index: 999;
13 13 margin: 0;
... ... @@ -80,7 +80,7 @@ div.token-input-dropdown-facebook {
80 80 border-bottom: 1px solid #ccc;
81 81 cursor: default;
82 82 font-size: 11px;
83   - font-family: Verdana;
  83 + font-family: Verdana, sans-serif;
84 84 z-index: 1;
85 85 }
86 86  
... ... @@ -119,8 +119,4 @@ div.token-input-dropdown-facebook ul li em {
119 119 div.token-input-dropdown-facebook ul li.token-input-selected-dropdown-item-facebook {
120 120 background-color: #3b5998;
121 121 color: #fff;
122   -}
123   -.token-input-blur-text-facebook {
124   - font-style: italic;
125   - color: #AAA;
126   -}
  122 +}
127 123 \ No newline at end of file
... ...
test/functional/profile_controller_test.rb
... ... @@ -7,6 +7,7 @@ class ProfileControllerTest &lt; ActionController::TestCase
7 7  
8 8 self.default_params = {profile: 'testuser'}
9 9 def setup
  10 + @controller = ProfileController.new
10 11 @profile = create_user('testuser').person
11 12 end
12 13 attr_reader :profile
... ... @@ -759,7 +760,7 @@ class ProfileControllerTest &lt; ActionController::TestCase
759 760  
760 761 login_as(profile.identifier)
761 762 get :index, :profile => p1.identifier
762   - assert_nil assigns(:activities)
  763 + assert assigns(:activities).blank?
763 764 end
764 765  
765 766 should 'see the activities_items paginated' do
... ... @@ -954,14 +955,14 @@ class ProfileControllerTest &lt; ActionController::TestCase
954 955 should 'not have activities defined if not logged in' do
955 956 p1= fast_create(Person)
956 957 get :index, :profile => p1.identifier
957   - assert_nil assigns(:actvities)
  958 + assert assigns(:actvities).blank?
958 959 end
959 960  
960 961 should 'not have activities defined if logged in but is not following profile' do
961 962 login_as(profile.identifier)
962 963 p1= fast_create(Person)
963 964 get :index, :profile => p1.identifier
964   - assert_nil assigns(:activities)
  965 + assert assigns(:activities).blank?
965 966 end
966 967  
967 968 should 'have activities defined if logged in and is following profile' do
... ... @@ -2045,4 +2046,218 @@ class ProfileControllerTest &lt; ActionController::TestCase
2045 2046 assert_redirected_to "/some/url"
2046 2047 end
2047 2048  
  2049 + should "search followed people or circles" do
  2050 + login_as(@profile.identifier)
  2051 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2052 + c2 = Circle.create!(:name => 'Work', :person => @profile, :profile_type => Person)
  2053 + p1 = create_user('emily').person
  2054 + p2 = create_user('wollie').person
  2055 + p3 = create_user('mary').person
  2056 + ProfileFollower.create!(:profile => p1, :circle => c2)
  2057 + ProfileFollower.create!(:profile => p2, :circle => c1)
  2058 + ProfileFollower.create!(:profile => p3, :circle => c1)
  2059 +
  2060 + get :search_followed, :q => 'mily'
  2061 + assert_equal 'Family (Circle)', json_response[0]['name']
  2062 + assert_equal 'Circle', json_response[0]['class']
  2063 + assert_equal "Circle_#{c1.id}", json_response[0]['id']
  2064 + assert_equal 'emily (Person)', json_response[1]['name']
  2065 + assert_equal 'Person', json_response[1]['class']
  2066 + assert_equal "Person_#{p1.id}", json_response[1]['id']
  2067 +
  2068 + get :search_followed, :q => 'wo'
  2069 + assert_equal 'Work (Circle)', json_response[0]['name']
  2070 + assert_equal 'Circle', json_response[0]['class']
  2071 + assert_equal "Circle_#{c2.id}", json_response[0]['id']
  2072 + assert_equal 'wollie (Person)', json_response[1]['name']
  2073 + assert_equal 'Person', json_response[1]['class']
  2074 + assert_equal "Person_#{p2.id}", json_response[1]['id']
  2075 +
  2076 + get :search_followed, :q => 'mar'
  2077 + assert_equal 'mary (Person)', json_response[0]['name']
  2078 + assert_equal 'Person', json_response[0]['class']
  2079 + assert_equal "Person_#{p3.id}", json_response[0]['id']
  2080 + end
  2081 +
  2082 + should 'treat followed entries' do
  2083 + login_as(@profile.identifier)
  2084 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2085 + p1 = create_user('emily').person
  2086 + p2 = create_user('wollie').person
  2087 + p3 = create_user('mary').person
  2088 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2089 + ProfileFollower.create!(:profile => p3, :circle => c1)
  2090 +
  2091 + entries = "Circle_#{c1.id},Person_#{p1.id},Person_#{p2.id}"
  2092 + @controller.stubs(:profile).returns(@profile)
  2093 + @controller.stubs(:user).returns(@profile)
  2094 + marked_people = @controller.send(:treat_followed_entries, entries)
  2095 +
  2096 + assert_equivalent [p1,p2,p3], marked_people
  2097 + end
  2098 +
  2099 + should 'return empty followed entries if the user is not on his wall' do
  2100 + login_as(@profile.identifier)
  2101 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2102 + p1 = create_user('emily').person
  2103 + p2 = create_user('wollie').person
  2104 + p3 = create_user('mary').person
  2105 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2106 + ProfileFollower.create!(:profile => p3, :circle => c1)
  2107 +
  2108 + entries = "Circle_#{c1.id},Person_#{p1.id},Person_#{p2.id}"
  2109 + @controller.stubs(:profile).returns(@profile)
  2110 + @controller.stubs(:user).returns(p1)
  2111 + marked_people = @controller.send(:treat_followed_entries, entries)
  2112 +
  2113 + assert_empty marked_people
  2114 + end
  2115 +
  2116 + should 'leave private scrap' do
  2117 + login_as(@profile.identifier)
  2118 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2119 + p1 = create_user('emily').person
  2120 + p2 = create_user('wollie').person
  2121 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2122 + ProfileFollower.create!(:profile => p2, :circle => c1)
  2123 +
  2124 + content = 'Remember my birthday!'
  2125 +
  2126 + post :leave_scrap, :profile => @profile.identifier, :scrap => {:content => content}, :filter_followed => "Person_#{p1.id},Person_#{p2.id}"
  2127 +
  2128 + scrap = Scrap.last
  2129 + assert_equal content, scrap.content
  2130 + assert_equivalent [p1,p2], scrap.marked_people
  2131 + end
  2132 +
  2133 + should 'list private scraps on wall for marked people' do
  2134 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2135 + p1 = create_user('emily').person
  2136 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2137 + p1.add_friend(@profile)
  2138 + scrap = Scrap.create!(:content => 'Secret message.', :sender_id => @profile.id, :receiver_id => @profile.id, :marked_people => [p1])
  2139 + scrap_activity = ProfileActivity.where(:activity => scrap).first
  2140 + login_as(p1.identifier)
  2141 +
  2142 + get :index, :profile => @profile.identifier
  2143 +
  2144 + assert assigns(:activities).include?(scrap_activity)
  2145 + end
  2146 +
  2147 + should 'not list private scraps on wall for not marked people' do
  2148 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2149 + p1 = create_user('emily').person
  2150 + p2 = create_user('wollie').person
  2151 + not_marked = create_user('jack').person
  2152 + not_marked.add_friend(@profile)
  2153 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2154 + ProfileFollower.create!(:profile => p2, :circle => c1)
  2155 + ProfileFollower.create!(:profile => not_marked, :circle => c1)
  2156 + scrap = Scrap.create!(:content => 'Secret message.', :sender_id => @profile.id, :receiver_id => @profile.id, :marked_people => [p1,p2])
  2157 + scrap_activity = ProfileActivity.where(:activity => scrap).first
  2158 + login_as(not_marked.identifier)
  2159 +
  2160 + get :index, :profile => @profile.identifier
  2161 +
  2162 + assert !assigns(:activities).include?(scrap_activity)
  2163 + end
  2164 +
  2165 + should 'list private scraps on wall for creator' do
  2166 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2167 + p1 = create_user('emily').person
  2168 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2169 + scrap = Scrap.create!(:content => 'Secret message.', :sender_id => @profile.id, :receiver_id => @profile.id, :marked_people => [p1])
  2170 + scrap_activity = ProfileActivity.where(:activity => scrap).first
  2171 + login_as(@profile.identifier)
  2172 +
  2173 + get :index, :profile => @profile.identifier
  2174 +
  2175 + assert assigns(:activities).include?(scrap_activity)
  2176 + end
  2177 +
  2178 + should 'list private scraps on wall for environment administrator' do
  2179 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2180 + p1 = create_user('emily').person
  2181 + admin = create_user('env-admin').person
  2182 + env = @profile.environment
  2183 + env.add_admin(admin)
  2184 + admin.add_friend(@profile)
  2185 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2186 + scrap = Scrap.create!(:content => 'Secret message.', :sender_id => @profile.id, :receiver_id => @profile.id, :marked_people => [p1])
  2187 + scrap_activity = ProfileActivity.where(:activity => scrap).first
  2188 + login_as(admin.identifier)
  2189 +
  2190 + get :index, :profile => @profile.identifier
  2191 +
  2192 + assert assigns(:activities).include?(scrap_activity)
  2193 + end
  2194 +
  2195 + should 'list private scraps on network for marked people' do
  2196 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2197 + p1 = create_user('emily').person
  2198 + p2 = create_user('wollie').person
  2199 + p2.add_friend(p1)
  2200 + p1.add_friend(p2)
  2201 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2202 + ProfileFollower.create!(:profile => p2, :circle => c1)
  2203 + scrap = Scrap.create!(:content => 'Secret message.', :sender_id => @profile.id, :receiver_id => @profile.id, :marked_people => [p1,p2])
  2204 + process_delayed_job_queue
  2205 + scrap_activity = p1.tracked_notifications.where(:target => scrap).first
  2206 + login_as(p2.identifier)
  2207 +
  2208 + get :index, :profile => p1.identifier
  2209 +
  2210 + assert assigns(:network_activities).include?(scrap_activity)
  2211 + end
  2212 +
  2213 + should 'not list private scraps on network for not marked people' do
  2214 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2215 + p1 = create_user('emily').person
  2216 + not_marked = create_user('jack').person
  2217 + not_marked.add_friend(p1)
  2218 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2219 + ProfileFollower.create!(:profile => not_marked, :circle => c1)
  2220 + scrap = Scrap.create!(:content => 'Secret message.', :sender_id => @profile.id, :receiver_id => @profile.id, :marked_people => [p1])
  2221 + process_delayed_job_queue
  2222 + scrap_activity = p1.tracked_notifications.where(:target => scrap).first
  2223 + login_as(not_marked.identifier)
  2224 +
  2225 + get :index, :profile => p1.identifier
  2226 +
  2227 + assert !assigns(:network_activities).include?(scrap_activity)
  2228 + end
  2229 +
  2230 + should 'list private scraps on network for creator' do
  2231 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2232 + p1 = create_user('emily').person
  2233 + p1.add_friend(@profile)
  2234 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2235 + scrap = Scrap.create!(:content => 'Secret message.', :sender_id => @profile.id, :receiver_id => @profile.id, :marked_people => [p1])
  2236 + process_delayed_job_queue
  2237 + scrap_activity = p1.tracked_notifications.where(:target => scrap).first
  2238 + login_as(@profile.identifier)
  2239 +
  2240 + get :index, :profile => p1.identifier
  2241 +
  2242 + assert assigns(:network_activities).include?(scrap_activity)
  2243 + end
  2244 +
  2245 + should 'list private scraps on network for environment admin' do
  2246 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2247 + p1 = create_user('emily').person
  2248 + admin = create_user('env-admin').person
  2249 + env = @profile.environment
  2250 + env.add_admin(admin)
  2251 + admin.add_friend(p1)
  2252 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2253 + scrap = Scrap.create!(:content => 'Secret message.', :sender_id => @profile.id, :receiver_id => @profile.id, :marked_people => [p1])
  2254 + process_delayed_job_queue
  2255 + scrap_activity = p1.tracked_notifications.where(:target => scrap).first
  2256 + login_as(admin.identifier)
  2257 +
  2258 + get :index, :profile => p1.identifier
  2259 +
  2260 + assert assigns(:network_activities).include?(scrap_activity)
  2261 + end
  2262 +
2048 2263 end
... ...
test/test_helper.rb
... ... @@ -199,5 +199,9 @@ class ActiveSupport::TestCase
199 199 ret
200 200 end
201 201  
  202 + def json_response
  203 + ActiveSupport::JSON.decode(@response.body)
  204 + end
  205 +
202 206 end
203 207  
... ...
test/unit/notify_activity_to_profiles_job_test.rb
... ... @@ -52,6 +52,27 @@ class NotifyActivityToProfilesJobTest &lt; ActiveSupport::TestCase
52 52 end
53 53 end
54 54  
  55 + should 'notify only marked people on marked scraps' do
  56 + profile = create_user('scrap-creator').person
  57 + c1 = Circle.create!(:name => 'Family', :person => profile, :profile_type => Person)
  58 + p1 = create_user('emily').person
  59 + p2 = create_user('wollie').person
  60 + not_marked = create_user('jack').person
  61 + not_marked.add_friend(p1)
  62 + not_marked.add_friend(p2)
  63 + not_marked.add_friend(profile)
  64 + ProfileFollower.create!(:profile => p1, :circle => c1)
  65 + ProfileFollower.create!(:profile => p2, :circle => c1)
  66 + ProfileFollower.create!(:profile => not_marked, :circle => c1)
  67 +
  68 + scrap = Scrap.create!(:content => 'Secret message.', :sender_id => profile.id, :receiver_id => profile.id, :marked_people => [p1,p2])
  69 + process_delayed_job_queue
  70 +
  71 + assert p1.tracked_notifications.where(:target => scrap).present?
  72 + assert p2.tracked_notifications.where(:target => scrap).present?
  73 + assert not_marked.tracked_notifications.where(:target => scrap).blank?
  74 + end
  75 +
55 76 should 'not notify the communities members' do
56 77 person = fast_create(Person)
57 78 community = fast_create(Community)
... ...