Commit fca616032cf3c305806a8a121639ea6dcb90fd93

Authored by Rodrigo Souto
1 parent 0380e1c7
Exists in private-scraps

private-scrasp: send scrap only to marked people

app/controllers/public/profile_controller.rb
... ... @@ -18,6 +18,11 @@ class ProfileController < PublicController
18 18 @network_activities = @profile.tracked_notifications.visible.paginate(:per_page => 15, :page => params[:page]) if @network_activities.empty?
19 19 @activities = @profile.activities.paginate(:per_page => 15, :page => params[:page])
20 20 end
  21 +
  22 + # TODO Find a way to filter these through sql
  23 + @network_activities = filter_private_scraps(@network_activities)
  24 + @activities = filter_private_scraps(@activities)
  25 +
21 26 @tags = profile.article_tags
22 27 allow_access_to_page
23 28 end
... ... @@ -217,6 +222,7 @@ class ProfileController < PublicController
217 222 @scrap = Scrap.new(params[:scrap])
218 223 @scrap.sender= sender
219 224 @scrap.receiver= receiver
  225 + @scrap.marked_people = treat_followed_entries(params[:filter_followed])
220 226 @tab_action = params[:tab_action]
221 227 @message = @scrap.save ? _("Message successfully sent.") : _("You can't leave an empty message.")
222 228 activities = @profile.activities.paginate(:per_page => 15, :page => params[:page]) if params[:not_load_scraps].nil?
... ... @@ -239,6 +245,14 @@ class ProfileController < PublicController
239 245 end
240 246 end
241 247  
  248 + def search_followed
  249 + result = []
  250 + circles = find_by_contents(:circles, user, user.circles.where(:profile_type => 'Person'), params[:q])[:results]
  251 + followed = find_by_contents(:followed, user, Profile.followed_by(user), params[:q])[:results]
  252 + result = circles + followed
  253 + render :text => prepare_to_token_input_by_class(result).to_json
  254 + end
  255 +
242 256 def view_more_activities
243 257 @activities = @profile.activities.paginate(:per_page => 10, :page => params[:page])
244 258 render :partial => 'profile_activities_list', :locals => {:activities => @activities}
... ... @@ -420,7 +434,6 @@ class ProfileController < PublicController
420 434 end
421 435 end
422 436  
423   -
424 437 protected
425 438  
426 439 def check_access_to_profile
... ... @@ -466,4 +479,41 @@ class ProfileController < PublicController
466 479 render_not_found unless profile.allow_followers?
467 480 end
468 481  
  482 + def treat_followed_entries(entries)
  483 + return [] if entries.blank? || profile != user
  484 +
  485 + followed = []
  486 + entries.split(',').map do |entry|
  487 + klass, identifier = entry.split('_')
  488 + case klass
  489 + when 'Person'
  490 + followed << Person.find(identifier)
  491 + when 'Circle'
  492 + circle = Circle.find(identifier)
  493 + followed += Profile.in_circle(circle)
  494 + end
  495 + end
  496 + followed.uniq
  497 + end
  498 +
  499 + def filter_private_scraps(activities)
  500 + activities = Array(activities)
  501 + activities.delete_if do |item|
  502 + if item.kind_of?(ProfileActivity)
  503 + target = item.activity
  504 + owner = profile
  505 + else
  506 + target = item.target
  507 + owner = item.user
  508 + end
  509 + !environment.admins.include?(user) &&
  510 + #TODO Consider this if allowing to mark people on organization's wall
  511 + #!profile.admins.include?(user) &&
  512 + owner != user &&
  513 + target.is_a?(Scrap) &&
  514 + target.marked_people.present? &&
  515 + !target.marked_people.include?(user)
  516 + end
  517 + activities
  518 + end
469 519 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,9 +19,14 @@ 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   - #TODO fix spaming notifications when following and unfolling many times
23   - # Notify all followers
24   - 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 + #TODO fix spaming notifications when following and unfolling many times
  27 + # Notify all followers
  28 + 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}))")
  29 + end
25 30  
26 31 if tracked_action.user.is_a? Organization
27 32 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  
... ... @@ -17,5 +23,4 @@ class Circle &lt; ApplicationRecord
17 23 scope :with_name, -> name{
18 24 where(:name => name)
19 25 }
20   -
21 26 end
... ...
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: 20160422163123) 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"
... ... @@ -274,6 +274,14 @@ ActiveRecord::Schema.define(version: 20160422163123) do
274 274 add_index "chat_messages", ["from_id"], name: "index_chat_messages_on_from_id", using: :btree
275 275 add_index "chat_messages", ["to_id"], name: "index_chat_messages_on_to_id", using: :btree
276 276  
  277 + create_table "circles", force: :cascade do |t|
  278 + t.string "name"
  279 + t.integer "person_id"
  280 + t.string "profile_type", null: false
  281 + end
  282 +
  283 + add_index "circles", ["person_id", "name"], name: "circles_composite_key_index", unique: true, using: :btree
  284 +
277 285 create_table "comments", force: :cascade do |t|
278 286 t.string "title"
279 287 t.text "body"
... ... @@ -516,6 +524,11 @@ ActiveRecord::Schema.define(version: 20160422163123) do
516 524 t.datetime "updated_at"
517 525 end
518 526  
  527 + create_table "private_scraps", force: :cascade do |t|
  528 + t.integer "person_id"
  529 + t.integer "scrap_id"
  530 + end
  531 +
519 532 create_table "product_qualifiers", force: :cascade do |t|
520 533 t.integer "product_id"
521 534 t.integer "qualifier_id"
... ... @@ -639,6 +652,15 @@ ActiveRecord::Schema.define(version: 20160422163123) do
639 652 add_index "profiles", ["user_id", "type"], name: "index_profiles_on_user_id_and_type", using: :btree
640 653 add_index "profiles", ["user_id"], name: "index_profiles_on_user_id", using: :btree
641 654  
  655 + create_table "profiles_circles", force: :cascade do |t|
  656 + t.integer "profile_id"
  657 + t.integer "circle_id"
  658 + t.datetime "created_at"
  659 + t.datetime "updated_at"
  660 + end
  661 +
  662 + add_index "profiles_circles", ["profile_id", "circle_id"], name: "profiles_circles_composite_key_index", unique: true, using: :btree
  663 +
642 664 create_table "qualifier_certifiers", force: :cascade do |t|
643 665 t.integer "qualifier_id"
644 666 t.integer "certifier_id"
... ... @@ -860,4 +882,5 @@ ActiveRecord::Schema.define(version: 20160422163123) do
860 882 add_index "votes", ["voteable_id", "voteable_type"], name: "fk_voteables", using: :btree
861 883 add_index "votes", ["voter_id", "voter_type"], name: "fk_voters", using: :btree
862 884  
  885 + add_foreign_key "profiles_circles", "circles", on_delete: :nullify
863 886 end
... ...
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
... ... @@ -952,14 +953,14 @@ class ProfileControllerTest &lt; ActionController::TestCase
952 953 should 'not have activities defined if not logged in' do
953 954 p1= fast_create(Person)
954 955 get :index, :profile => p1.identifier
955   - assert_nil assigns(:actvities)
  956 + assert assigns(:actvities).blank?
956 957 end
957 958  
958 959 should 'not have activities defined if logged in but is not following profile' do
959 960 login_as(profile.identifier)
960 961 p1= fast_create(Person)
961 962 get :index, :profile => p1.identifier
962   - assert_nil assigns(:activities)
  963 + assert assigns(:activities).blank?
963 964 end
964 965  
965 966 should 'have activities defined if logged in and is following profile' do
... ... @@ -2008,4 +2009,216 @@ class ProfileControllerTest &lt; ActionController::TestCase
2008 2009 assert_redirected_to "/some/url"
2009 2010 end
2010 2011  
  2012 + should "search followed people or circles" do
  2013 + login_as(@profile.identifier)
  2014 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2015 + c2 = Circle.create!(:name => 'Work', :person => @profile, :profile_type => Person)
  2016 + p1 = create_user('emily').person
  2017 + p2 = create_user('wollie').person
  2018 + p3 = create_user('mary').person
  2019 + ProfileFollower.create!(:profile => p1, :circle => c2)
  2020 + ProfileFollower.create!(:profile => p2, :circle => c1)
  2021 + ProfileFollower.create!(:profile => p3, :circle => c1)
  2022 +
  2023 + get :search_followed, :q => 'mily'
  2024 + assert_equal 'Family (Circle)', json_response[0]['name']
  2025 + assert_equal 'Circle', json_response[0]['class']
  2026 + assert_equal "Circle_#{c1.id}", json_response[0]['id']
  2027 + assert_equal 'emily (Person)', json_response[1]['name']
  2028 + assert_equal 'Person', json_response[1]['class']
  2029 + assert_equal "Person_#{p1.id}", json_response[1]['id']
  2030 +
  2031 + get :search_followed, :q => 'wo'
  2032 + assert_equal 'Work (Circle)', json_response[0]['name']
  2033 + assert_equal 'Circle', json_response[0]['class']
  2034 + assert_equal "Circle_#{c2.id}", json_response[0]['id']
  2035 + assert_equal 'wollie (Person)', json_response[1]['name']
  2036 + assert_equal 'Person', json_response[1]['class']
  2037 + assert_equal "Person_#{p2.id}", json_response[1]['id']
  2038 +
  2039 + get :search_followed, :q => 'mar'
  2040 + assert_equal 'mary (Person)', json_response[0]['name']
  2041 + assert_equal 'Person', json_response[0]['class']
  2042 + assert_equal "Person_#{p3.id}", json_response[0]['id']
  2043 + end
  2044 +
  2045 + should 'treat followed entries' do
  2046 + login_as(@profile.identifier)
  2047 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2048 + p1 = create_user('emily').person
  2049 + p2 = create_user('wollie').person
  2050 + p3 = create_user('mary').person
  2051 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2052 + ProfileFollower.create!(:profile => p3, :circle => c1)
  2053 +
  2054 + entries = "Circle_#{c1.id},Person_#{p1.id},Person_#{p2.id}"
  2055 + marked_people = @controller.send(:treat_followed_entries, entries)
  2056 +
  2057 + assert_equivalent [p1,p2,p3], marked_people
  2058 + end
  2059 +
  2060 + should 'return empty followed entries if the user is not on his wall' do
  2061 + login_as(@profile.identifier)
  2062 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2063 + p1 = create_user('emily').person
  2064 + p2 = create_user('wollie').person
  2065 + p3 = create_user('mary').person
  2066 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2067 + ProfileFollower.create!(:profile => p3, :circle => c1)
  2068 +
  2069 + entries = "Circle_#{c1.id},Person_#{p1.id},Person_#{p2.id}"
  2070 + @controller.stubs(:profile).returns(@profile)
  2071 + @controller.stubs(:user).returns(p1)
  2072 + marked_people = @controller.send(:treat_followed_entries, entries)
  2073 +
  2074 + assert_empty marked_people
  2075 + end
  2076 +
  2077 + should 'leave private scrap' do
  2078 + login_as(@profile.identifier)
  2079 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2080 + p1 = create_user('emily').person
  2081 + p2 = create_user('wollie').person
  2082 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2083 + ProfileFollower.create!(:profile => p2, :circle => c1)
  2084 +
  2085 + content = 'Remember my birthday!'
  2086 +
  2087 + post :leave_scrap, :profile => @profile.identifier, :scrap => {:content => content}, :filter_followed => "Person_#{p1.id},Person_#{p2.id}"
  2088 +
  2089 + scrap = Scrap.last
  2090 + assert_equal content, scrap.content
  2091 + assert_equivalent [p1,p2], scrap.marked_people
  2092 + end
  2093 +
  2094 + should 'list private scraps on wall for marked people' do
  2095 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2096 + p1 = create_user('emily').person
  2097 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2098 + p1.add_friend(@profile)
  2099 + scrap = Scrap.create!(:content => 'Secret message.', :sender_id => @profile.id, :receiver_id => @profile.id, :marked_people => [p1])
  2100 + scrap_activity = ProfileActivity.where(:activity => scrap).first
  2101 + login_as(p1.identifier)
  2102 +
  2103 + get :index, :profile => @profile.identifier
  2104 +
  2105 + assert assigns(:activities).include?(scrap_activity)
  2106 + end
  2107 +
  2108 + should 'not list private scraps on wall for not marked people' do
  2109 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2110 + p1 = create_user('emily').person
  2111 + p2 = create_user('wollie').person
  2112 + not_marked = create_user('jack').person
  2113 + not_marked.add_friend(@profile)
  2114 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2115 + ProfileFollower.create!(:profile => p2, :circle => c1)
  2116 + ProfileFollower.create!(:profile => not_marked, :circle => c1)
  2117 + scrap = Scrap.create!(:content => 'Secret message.', :sender_id => @profile.id, :receiver_id => @profile.id, :marked_people => [p1,p2])
  2118 + scrap_activity = ProfileActivity.where(:activity => scrap).first
  2119 + login_as(not_marked.identifier)
  2120 +
  2121 + get :index, :profile => @profile.identifier
  2122 +
  2123 + assert !assigns(:activities).include?(scrap_activity)
  2124 + end
  2125 +
  2126 + should 'list private scraps on wall for creator' do
  2127 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2128 + p1 = create_user('emily').person
  2129 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2130 + scrap = Scrap.create!(:content => 'Secret message.', :sender_id => @profile.id, :receiver_id => @profile.id, :marked_people => [p1])
  2131 + scrap_activity = ProfileActivity.where(:activity => scrap).first
  2132 + login_as(@profile.identifier)
  2133 +
  2134 + get :index, :profile => @profile.identifier
  2135 +
  2136 + assert assigns(:activities).include?(scrap_activity)
  2137 + end
  2138 +
  2139 + should 'list private scraps on wall for environment administrator' do
  2140 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2141 + p1 = create_user('emily').person
  2142 + admin = create_user('env-admin').person
  2143 + env = @profile.environment
  2144 + env.add_admin(admin)
  2145 + admin.add_friend(@profile)
  2146 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2147 + scrap = Scrap.create!(:content => 'Secret message.', :sender_id => @profile.id, :receiver_id => @profile.id, :marked_people => [p1])
  2148 + scrap_activity = ProfileActivity.where(:activity => scrap).first
  2149 + login_as(admin.identifier)
  2150 +
  2151 + get :index, :profile => @profile.identifier
  2152 +
  2153 + assert assigns(:activities).include?(scrap_activity)
  2154 + end
  2155 +
  2156 + should 'list private scraps on network for marked people' do
  2157 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2158 + p1 = create_user('emily').person
  2159 + p2 = create_user('wollie').person
  2160 + p2.add_friend(p1)
  2161 + p1.add_friend(p2)
  2162 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2163 + ProfileFollower.create!(:profile => p2, :circle => c1)
  2164 + scrap = Scrap.create!(:content => 'Secret message.', :sender_id => @profile.id, :receiver_id => @profile.id, :marked_people => [p1,p2])
  2165 + process_delayed_job_queue
  2166 + scrap_activity = p1.tracked_notifications.where(:target => scrap).first
  2167 + login_as(p2.identifier)
  2168 +
  2169 + get :index, :profile => p1.identifier
  2170 +
  2171 + assert assigns(:network_activities).include?(scrap_activity)
  2172 + end
  2173 +
  2174 + should 'not list private scraps on network for not marked people' do
  2175 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2176 + p1 = create_user('emily').person
  2177 + not_marked = create_user('jack').person
  2178 + not_marked.add_friend(p1)
  2179 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2180 + ProfileFollower.create!(:profile => not_marked, :circle => c1)
  2181 + scrap = Scrap.create!(:content => 'Secret message.', :sender_id => @profile.id, :receiver_id => @profile.id, :marked_people => [p1])
  2182 + process_delayed_job_queue
  2183 + scrap_activity = p1.tracked_notifications.where(:target => scrap).first
  2184 + login_as(not_marked.identifier)
  2185 +
  2186 + get :index, :profile => p1.identifier
  2187 +
  2188 + assert !assigns(:network_activities).include?(scrap_activity)
  2189 + end
  2190 +
  2191 + should 'list private scraps on network for creator' do
  2192 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2193 + p1 = create_user('emily').person
  2194 + p1.add_friend(@profile)
  2195 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2196 + scrap = Scrap.create!(:content => 'Secret message.', :sender_id => @profile.id, :receiver_id => @profile.id, :marked_people => [p1])
  2197 + process_delayed_job_queue
  2198 + scrap_activity = p1.tracked_notifications.where(:target => scrap).first
  2199 + login_as(@profile.identifier)
  2200 +
  2201 + get :index, :profile => p1.identifier
  2202 +
  2203 + assert assigns(:network_activities).include?(scrap_activity)
  2204 + end
  2205 +
  2206 + should 'list private scraps on network for environment admin' do
  2207 + c1 = Circle.create!(:name => 'Family', :person => @profile, :profile_type => Person)
  2208 + p1 = create_user('emily').person
  2209 + admin = create_user('env-admin').person
  2210 + env = @profile.environment
  2211 + env.add_admin(admin)
  2212 + admin.add_friend(p1)
  2213 + ProfileFollower.create!(:profile => p1, :circle => c1)
  2214 + scrap = Scrap.create!(:content => 'Secret message.', :sender_id => @profile.id, :receiver_id => @profile.id, :marked_people => [p1])
  2215 + process_delayed_job_queue
  2216 + scrap_activity = p1.tracked_notifications.where(:target => scrap).first
  2217 + login_as(admin.identifier)
  2218 +
  2219 + get :index, :profile => p1.identifier
  2220 +
  2221 + assert assigns(:network_activities).include?(scrap_activity)
  2222 + end
  2223 +
2011 2224 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
... ... @@ -46,6 +46,27 @@ class NotifyActivityToProfilesJobTest &lt; ActiveSupport::TestCase
46 46 end
47 47 end
48 48  
  49 + should 'notify only marked people on marked scraps' do
  50 + profile = create_user('scrap-creator').person
  51 + c1 = Circle.create!(:name => 'Family', :person => profile, :profile_type => Person)
  52 + p1 = create_user('emily').person
  53 + p2 = create_user('wollie').person
  54 + not_marked = create_user('jack').person
  55 + not_marked.add_friend(p1)
  56 + not_marked.add_friend(p2)
  57 + not_marked.add_friend(profile)
  58 + ProfileFollower.create!(:profile => p1, :circle => c1)
  59 + ProfileFollower.create!(:profile => p2, :circle => c1)
  60 + ProfileFollower.create!(:profile => not_marked, :circle => c1)
  61 +
  62 + scrap = Scrap.create!(:content => 'Secret message.', :sender_id => profile.id, :receiver_id => profile.id, :marked_people => [p1,p2])
  63 + process_delayed_job_queue
  64 +
  65 + assert p1.tracked_notifications.where(:target => scrap).present?
  66 + assert p2.tracked_notifications.where(:target => scrap).present?
  67 + assert not_marked.tracked_notifications.where(:target => scrap).blank?
  68 + end
  69 +
49 70 should 'not notify the communities members' do
50 71 person = fast_create(Person)
51 72 community = fast_create(Community)
... ...