Commit 988d1e617dbd7f95de6bb822f95a2a7a8663fb5e

Authored by Rodrigo Souto
Committed by Marcos Pereira
1 parent 020591c2

private-scraps: send scrap only to marked people

(cherry picked from commit fca616032cf3c305806a8a121639ea6dcb90fd93)
app/controllers/public/profile_controller.rb
... ... @@ -19,6 +19,11 @@ class ProfileController < PublicController
19 19 @network_activities = @profile.tracked_notifications.visible.paginate(:per_page => 15, :page => params[:page]) if @network_activities.empty?
20 20 @activities = @profile.activities.paginate(:per_page => 15, :page => params[:page])
21 21 end
  22 +
  23 + # TODO Find a way to filter these through sql
  24 + @network_activities = filter_private_scraps(@network_activities)
1
  • 40978867608e81b4fb066c2e92f45620?s=40&d=identicon
    Larissa Reis @larissareis

    @diguliu vi agora que você filtra isso depois de fazer o paginate. Isso dá problema porque pode filtrar todos e acabar tendo uma página vazia. Por outro lado, se fizer antes do paginate, precisa de algo com maior desempenho. Então vamos precisar de usar uma SQL mesmo.

    Choose File ...   File name...
    Cancel
  25 + @activities = filter_private_scraps(@activities)
  26 +
22 27 @tags = profile.article_tags
23 28 allow_access_to_page
24 29 end
... ... @@ -231,6 +236,7 @@ class ProfileController < PublicController
231 236 @scrap = Scrap.new(params[:scrap])
232 237 @scrap.sender= sender
233 238 @scrap.receiver= receiver
  239 + @scrap.marked_people = treat_followed_entries(params[:filter_followed])
234 240 @tab_action = params[:tab_action]
235 241 @message = @scrap.save ? _("Message successfully sent.") : _("You can't leave an empty message.")
236 242 activities = @profile.activities.paginate(:per_page => 15, :page => params[:page]) if params[:not_load_scraps].nil?
... ... @@ -253,6 +259,14 @@ class ProfileController < PublicController
253 259 end
254 260 end
255 261  
  262 + def search_followed
  263 + result = []
  264 + circles = find_by_contents(:circles, user, user.circles.where(:profile_type => 'Person'), params[:q])[:results]
  265 + followed = find_by_contents(:followed, user, Profile.followed_by(user), params[:q])[:results]
  266 + result = circles + followed
  267 + render :text => prepare_to_token_input_by_class(result).to_json
  268 + end
  269 +
256 270 def view_more_activities
257 271 @activities = @profile.activities.paginate(:per_page => 10, :page => params[:page])
258 272 render :partial => 'profile_activities_list', :locals => {:activities => @activities}
... ... @@ -434,7 +448,6 @@ class ProfileController < PublicController
434 448 end
435 449 end
436 450  
437   -
438 451 protected
439 452  
440 453 def check_access_to_profile
... ... @@ -480,4 +493,41 @@ class ProfileController < PublicController
480 493 render_not_found unless profile.allow_followers?
481 494 end
482 495  
  496 + def treat_followed_entries(entries)
  497 + return [] if entries.blank? || profile != user
  498 +
  499 + followed = []
  500 + entries.split(',').map do |entry|
  501 + klass, identifier = entry.split('_')
  502 + case klass
  503 + when 'Person'
  504 + followed << Person.find(identifier)
  505 + when 'Circle'
  506 + circle = Circle.find(identifier)
  507 + followed += Profile.in_circle(circle)
  508 + end
  509 + end
  510 + followed.uniq
  511 + end
  512 +
  513 + def filter_private_scraps(activities)
  514 + activities = Array(activities)
  515 + activities.delete_if do |item|
  516 + if item.kind_of?(ProfileActivity)
  517 + target = item.activity
  518 + owner = profile
  519 + else
  520 + target = item.target
  521 + owner = item.user
  522 + end
  523 + !environment.admins.include?(user) &&
  524 + #TODO Consider this if allowing to mark people on organization's wall
  525 + #!profile.admins.include?(user) &&
  526 + owner != user &&
  527 + target.is_a?(Scrap) &&
  528 + target.marked_people.present? &&
  529 + !target.marked_people.include?(user)
  530 + end
  531 + activities
  532 + end
483 533 end
... ...
app/helpers/article_helper.rb
... ... @@ -160,6 +160,10 @@ module ArticleHelper
160 160 array.map { |object| {:label => object.name, :value => object.name} }
161 161 end
162 162  
  163 + def prepare_to_token_input_by_class(array)
  164 + array.map { |object| {:id => "#{object.class.name}_#{object.id || object.name}", :name => "#{object.name} (#{_(object.class.name)})", :class => object.class.name}}
  165 + end
  166 +
163 167 def cms_label_for_new_children
164 168 _('New article')
165 169 end
... ...
app/helpers/token_helper.rb
... ... @@ -5,10 +5,11 @@ module TokenHelper
5 5 end
6 6  
7 7 def token_input_field_tag(name, element_id, search_action, options = {}, text_field_options = {}, html_options = {})
8   - options[:min_chars] ||= 3
  8 + options[:min_chars] ||= 2
9 9 options[:hint_text] ||= _("Type in a search term")
10 10 options[:no_results_text] ||= _("No results")
11 11 options[:searching_text] ||= _("Searching...")
  12 + options[:placeholder] ||= 'null'
12 13 options[:search_delay] ||= 1000
13 14 options[:prevent_duplicates] ||= true
14 15 options[:backspace_delete_item] ||= false
... ... @@ -20,6 +21,9 @@ module TokenHelper
20 21 options[:on_delete] ||= 'null'
21 22 options[:on_ready] ||= 'null'
22 23 options[:query_param] ||= 'q'
  24 + options[:theme] ||= 'null'
  25 + options[:results_formatter] ||= 'null'
  26 + options[:token_formatter] ||= 'null'
23 27  
24 28 result = text_field_tag(name, nil, text_field_options.merge(html_options.merge({:id => element_id})))
25 29 result += javascript_tag("jQuery('##{element_id}')
... ... @@ -29,6 +33,7 @@ module TokenHelper
29 33 hintText: #{options[:hint_text].to_json},
30 34 noResultsText: #{options[:no_results_text].to_json},
31 35 searchingText: #{options[:searching_text].to_json},
  36 + placeholder: #{options[:placeholder].to_json},
32 37 searchDelay: #{options[:search_delay].to_json},
33 38 preventDuplicates: #{options[:prevent_duplicates].to_json},
34 39 backspaceDeleteItem: #{options[:backspace_delete_item].to_json},
... ... @@ -39,6 +44,9 @@ module TokenHelper
39 44 onAdd: #{options[:on_add]},
40 45 onDelete: #{options[:on_delete]},
41 46 onReady: #{options[:on_ready]},
  47 + theme: #{options[:theme] == 'null' ? options[:theme] : options[:theme].to_json},
  48 + resultsFormater: #{options[:results_formatter]},
  49 + tokenFormater: #{options[:token_formatter]},
42 50 });
43 51 ")
44 52 result += javascript_tag("jQuery('##{element_id}').focus();") if options[:focus]
... ...
app/jobs/notify_activity_to_profiles_job.rb
... ... @@ -19,8 +19,13 @@ class NotifyActivityToProfilesJob &lt; Struct.new(:tracked_action_id)
19 19 # Notify the user
20 20 ActionTrackerNotification.create(:profile_id => tracked_action.user.id, :action_tracker_id => tracked_action.id)
21 21  
22   - # Notify all followers
23   - ActionTrackerNotification.connection.execute("INSERT INTO action_tracker_notifications(profile_id, action_tracker_id) SELECT DISTINCT c.person_id, #{tracked_action.id} FROM profiles_circles AS p JOIN circles as c ON c.id = p.circle_id WHERE p.profile_id = #{tracked_action.user.id} AND (c.person_id NOT IN (SELECT atn.profile_id FROM action_tracker_notifications AS atn WHERE atn.action_tracker_id = #{tracked_action.id}))")
  22 + if target.is_a?(Scrap) && target.marked_people.present?
  23 + # Notify only marked people
  24 + ActionTrackerNotification.connection.execute("INSERT INTO action_tracker_notifications(profile_id, action_tracker_id) SELECT DISTINCT profiles.id, #{tracked_action.id} FROM profiles WHERE profiles.id IN (#{target.marked_people.map(&:id).join(',')})")
  25 + else
  26 + # Notify all followers
  27 + ActionTrackerNotification.connection.execute("INSERT INTO action_tracker_notifications(profile_id, action_tracker_id) SELECT DISTINCT c.person_id, #{tracked_action.id} FROM profiles_circles AS p JOIN circles as c ON c.id = p.circle_id WHERE p.profile_id = #{tracked_action.user.id} AND (c.person_id NOT IN (SELECT atn.profile_id FROM action_tracker_notifications AS atn WHERE atn.action_tracker_id = #{tracked_action.id}))")
  28 + end
24 29  
25 30 if tracked_action.user.is_a? Organization
26 31 ActionTrackerNotification.connection.execute "insert into action_tracker_notifications(profile_id, action_tracker_id) " +
... ...
app/models/circle.rb
1 1 class Circle < ApplicationRecord
  2 + SEARCHABLE_FIELDS = {
  3 + :name => {:label => _('Name'), :weight => 1}
  4 + }
  5 +
  6 + _('Circle')
  7 +
2 8 has_many :profile_followers
3 9 belongs_to :person
4 10  
... ...
app/models/person.rb
... ... @@ -121,6 +121,8 @@ class Person &lt; Profile
121 121 where 'profile_suggestions.suggestion_type = ? AND profile_suggestions.enabled = ?', 'Community', true
122 122 }, through: :suggested_profiles, source: :suggestion
123 123  
  124 + has_and_belongs_to_many :marked_scraps, :join_table => :private_scraps, :class_name => 'Scrap'
  125 +
124 126 scope :more_popular, -> { order 'friends_count DESC' }
125 127  
126 128 scope :abusers, -> {
... ...
app/models/scrap.rb
... ... @@ -2,7 +2,7 @@ class Scrap &lt; ApplicationRecord
2 2  
3 3 include SanitizeHelper
4 4  
5   - attr_accessible :content, :sender_id, :receiver_id, :scrap_id
  5 + attr_accessible :content, :sender_id, :receiver_id, :scrap_id, :marked_people
6 6  
7 7 SEARCHABLE_FIELDS = {
8 8 :content => {:label => _('Content'), :weight => 1},
... ... @@ -19,6 +19,8 @@ class Scrap &lt; ApplicationRecord
19 19 where profile_activities: {activity_type: 'Scrap'}
20 20 }, foreign_key: :activity_id, dependent: :destroy
21 21  
  22 + has_and_belongs_to_many :marked_people, :join_table => :private_scraps, :class_name => 'Person'
  23 +
22 24 after_create :create_activity
23 25 after_update :update_activity
24 26  
... ...
app/views/profile/_profile_wall.html.erb
1 1 <h3><%= _("%s's wall") % @profile.name %></h3>
2 2 <div id='leave_scrap'>
3 3 <%= flash[:error] %>
4   - <%= form_remote_tag :url => {:controller => 'profile', :action => 'leave_scrap', :tab_action => 'wall' }, :update => 'profile_activities', :success => "jQuery('#leave_scrap_content').val('')", :complete => "jQuery('#leave_scrap_form').removeClass('loading').find('*').attr('disabled', false)", :loading => "jQuery('#leave_scrap_form').addClass('loading').find('*').attr('disabled', true)", :html => {:id => 'leave_scrap_form' } do %>
5   - <%= limited_text_area :scrap, :content, 420, 'leave_scrap_content', :cols => 50, :rows => 2, :class => 'autogrow' %>
  4 + <%= form_remote_tag :url => {:controller => 'profile', :action => 'leave_scrap', :tab_action => 'wall' }, :update => 'profile_activities', :success => "jQuery('#leave_scrap_content').val(''); jQuery('#filter-followed').tokenInput('clear')", :complete => "jQuery('#leave_scrap_form').removeClass('loading').find('*').attr('disabled', false)", :loading => "jQuery('#leave_scrap_form').addClass('loading').find('*').attr('disabled', true)", :html => {:id => 'leave_scrap_form' } do %>
  5 + <%= limited_text_area :scrap, :content, 420, 'leave_scrap_content', :rows => 2, :class => 'autogrow' %>
  6 + <% if profile == user %>
  7 + <%= token_input_field_tag(:filter_followed, 'filter-followed', {:action => 'search_followed'}, {:theme => 'facebook', :placeholder => _('Filter followed, friends or group of friends to send them a private scrap...')}) %>
  8 + <% end %>
6 9 <%= submit_button :new, _('Share') %>
7 10 <% end %>
8 11 </div>
... ...
db/migrate/20160705162914_create_private_scraps.rb 0 → 100644
... ... @@ -0,0 +1,8 @@
  1 +class CreatePrivateScraps < ActiveRecord::Migration
  2 + def change
  3 + create_table :private_scraps do |t|
  4 + t.references :person
  5 + t.references :scrap
  6 + end
  7 + end
  8 +end
... ...
db/schema.rb
... ... @@ -11,7 +11,7 @@
11 11 #
12 12 # It's strongly recommended that you check this file into your version control system.
13 13  
14   -ActiveRecord::Schema.define(version: 20160608123748) do
  14 +ActiveRecord::Schema.define(version: 20160705162914) do
15 15  
16 16 # These are extensions that must be enabled in order to support this database
17 17 enable_extension "plpgsql"
... ... @@ -524,6 +524,11 @@ ActiveRecord::Schema.define(version: 20160608123748) do
524 524 t.datetime "updated_at"
525 525 end
526 526  
  527 + create_table "private_scraps", force: :cascade do |t|
  528 + t.integer "person_id"
  529 + t.integer "scrap_id"
  530 + end
  531 +
527 532 create_table "product_qualifiers", force: :cascade do |t|
528 533 t.integer "product_id"
529 534 t.integer "qualifier_id"
... ...
public/javascripts/jquery.tokeninput.js
1 1 /*
2 2 * jQuery Plugin: Tokenizing Autocomplete Text Entry
3   - * Version 1.5.0
4   - * Requires jQuery 1.6+
  3 + * Version 1.6.2
5 4 *
6 5 * Copyright (c) 2009 James Smith (http://loopj.com)
7 6 * Licensed jointly under the GPL and MIT licenses,
8 7 * choose which one suits your project best!
9 8 *
10 9 */
11   -
12   -(function ($) {
13   -// Default settings
14   -var DEFAULT_SETTINGS = {
15   - hintText: "Type in a search term",
16   - noResultsText: "No results",
17   - searchingText: "Searching...",
18   - deleteText: "&times;",
  10 +;(function ($) {
  11 + var DEFAULT_SETTINGS = {
  12 + // Search settings
  13 + method: "GET",
  14 + queryParam: "q",
19 15 searchDelay: 300,
20 16 minChars: 1,
21   - permanentDropdown: false,
22   - showAllResults: false,
23   - tokenLimit: null,
  17 + propertyToSearch: "name",
24 18 jsonContainer: null,
25   - method: "GET",
26 19 contentType: "json",
27   - queryParam: "q",
28   - tokenDelimiter: ",",
29   - preventDuplicates: false,
  20 + excludeCurrent: false,
  21 + excludeCurrentParameter: "x",
  22 +
  23 + // Prepopulation settings
30 24 prePopulate: null,
31 25 processPrePopulate: false,
  26 +
  27 + // Display settings
  28 + hintText: "Type in a search term",
  29 + noResultsText: "No results",
  30 + searchingText: "Searching...",
  31 + deleteText: "&#215;",
32 32 animateDropdown: true,
33   - dontAdd: false,
  33 + placeholder: null,
  34 + theme: null,
  35 + zindex: 999,
  36 + resultsLimit: null,
  37 +
  38 + enableHTML: false,
  39 +
  40 + resultsFormatter: function(item) {
  41 + var string = item[this.propertyToSearch];
  42 + return "<li>" + (this.enableHTML ? string : _escapeHTML(string)) + "</li>";
  43 + },
  44 +
  45 + tokenFormatter: function(item) {
  46 + var string = item[this.propertyToSearch];
  47 + return "<li><p>" + (this.enableHTML ? string : _escapeHTML(string)) + "</p></li>";
  48 + },
  49 +
  50 + // Tokenization settings
  51 + tokenLimit: null,
  52 + tokenDelimiter: ",",
  53 + preventDuplicates: false,
  54 + tokenValue: "id",
  55 +
  56 + // Behavioral settings
  57 + allowFreeTagging: false,
  58 + allowTabOut: false,
  59 + autoSelectFirstResult: false,
  60 +
  61 + // Callbacks
34 62 onResult: null,
  63 + onCachedResult: null,
35 64 onAdd: null,
  65 + onFreeTaggingAdd: null,
36 66 onDelete: null,
  67 + onReady: null,
  68 +
  69 + // Other settings
37 70 idPrefix: "token-input-",
38   - zindex: 999,
39   - backspaceDeleteItem: true
40   -};
41   -
42   -// Default classes to use when theming
43   -var DEFAULT_CLASSES = {
44   - tokenList: "token-input-list",
45   - token: "token-input-token",
46   - tokenDelete: "token-input-delete-token",
47   - selectedToken: "token-input-selected-token",
48   - highlightedToken: "token-input-highlighted-token",
49   - dropdown: "token-input-dropdown",
50   - dropdownItem: "token-input-dropdown-item",
51   - dropdownItem2: "token-input-dropdown-item2",
52   - selectedDropdownItem: "token-input-selected-dropdown-item",
53   - inputToken: "token-input-input-token",
54   - blurText: "token-input-blur-text",
55   -};
56   -
57   -// Input box position "enum"
58   -var POSITION = {
59   - BEFORE: 0,
60   - AFTER: 1,
61   - END: 2
62   -};
63   -
64   -// Keys "enum"
65   -var KEY = {
66   - BACKSPACE: 8,
67   - DELETE: 46,
68   - TAB: 9,
69   - ENTER: 13,
70   - ESCAPE: 27,
71   - SPACE: 32,
72   - PAGE_UP: 33,
73   - PAGE_DOWN: 34,
74   - END: 35,
75   - HOME: 36,
76   - LEFT: 37,
77   - UP: 38,
78   - RIGHT: 39,
79   - DOWN: 40,
80   - NUMPAD_ENTER: 108,
81   - COMMA: 188
82   -};
83   -
84   -// Additional public (exposed) methods
85   -var methods = {
86   - init: function(url_or_data_or_function, options) {
87   - return this.each(function () {
88   - $(this).data("tokenInputObject", new $.TokenList(this, url_or_data_or_function, options));
89   - });
90   - },
91   - clear: function() {
92   - this.data("tokenInputObject").clear();
93   - return this;
94   - },
95   - add: function(item) {
96   - this.data("tokenInputObject").add(item);
97   - return this;
98   - },
99   - remove: function(item) {
100   - this.data("tokenInputObject").remove(item);
101   - return this;
102   - }
103   -}
104   -
105   -// Expose the .tokenInput function to jQuery as a plugin
106   -$.fn.tokenInput = function (method) {
107   - // Method calling and initialization logic
108   - if(methods[method]) {
109   - return methods[method].apply(this, Array.prototype.slice.call(arguments, 1));
110   - } else {
111   - return methods.init.apply(this, arguments);
112   - }
113   -};
114   -
115   -// TokenList class for each input
116   -$.TokenList = function (input, url_or_data, options) {
117   - //
118   - // Initialization
119   - //
120   - var settings = $.extend({}, DEFAULT_SETTINGS, options || {});
121   -
122   - // Configure the data source
123   - if(typeof(url_or_data) === "string") {
124   - // Set the url to query against
125   - settings.url = url_or_data;
126   -
127   - // Make a smart guess about cross-domain if it wasn't explicitly specified
128   - if(settings.crossDomain === undefined) {
129   - if(settings.url.indexOf("://") === -1) {
130   - settings.crossDomain = false;
131   - } else {
132   - settings.crossDomain = (location.href.split(/\/+/g)[1] !== settings.url.split(/\/+/g)[1]);
133   - }
  71 +
  72 + // Keep track if the input is currently in disabled mode
  73 + disabled: false
  74 + };
  75 +
  76 + // Default classes to use when theming
  77 + var DEFAULT_CLASSES = {
  78 + tokenList : "token-input-list",
  79 + token : "token-input-token",
  80 + tokenReadOnly : "token-input-token-readonly",
  81 + tokenDelete : "token-input-delete-token",
  82 + selectedToken : "token-input-selected-token",
  83 + highlightedToken : "token-input-highlighted-token",
  84 + dropdown : "token-input-dropdown",
  85 + dropdownItem : "token-input-dropdown-item",
  86 + dropdownItem2 : "token-input-dropdown-item2",
  87 + selectedDropdownItem : "token-input-selected-dropdown-item",
  88 + inputToken : "token-input-input-token",
  89 + focused : "token-input-focused",
  90 + disabled : "token-input-disabled"
  91 + };
  92 +
  93 + // Input box position "enum"
  94 + var POSITION = {
  95 + BEFORE : 0,
  96 + AFTER : 1,
  97 + END : 2
  98 + };
  99 +
  100 + // Keys "enum"
  101 + var KEY = {
  102 + BACKSPACE : 8,
  103 + TAB : 9,
  104 + ENTER : 13,
  105 + ESCAPE : 27,
  106 + SPACE : 32,
  107 + PAGE_UP : 33,
  108 + PAGE_DOWN : 34,
  109 + END : 35,
  110 + HOME : 36,
  111 + LEFT : 37,
  112 + UP : 38,
  113 + RIGHT : 39,
  114 + DOWN : 40,
  115 + NUMPAD_ENTER : 108,
  116 + COMMA : 188
  117 + };
  118 +
  119 + var HTML_ESCAPES = {
  120 + '&' : '&amp;',
  121 + '<' : '&lt;',
  122 + '>' : '&gt;',
  123 + '"' : '&quot;',
  124 + "'" : '&#x27;',
  125 + '/' : '&#x2F;'
  126 + };
  127 +
  128 + var HTML_ESCAPE_CHARS = /[&<>"'\/]/g;
  129 +
  130 + function coerceToString(val) {
  131 + return String((val === null || val === undefined) ? '' : val);
  132 + }
  133 +
  134 + function _escapeHTML(text) {
  135 + return coerceToString(text).replace(HTML_ESCAPE_CHARS, function(match) {
  136 + return HTML_ESCAPES[match];
  137 + });
  138 + }
  139 +
  140 + // Additional public (exposed) methods
  141 + var methods = {
  142 + init: function(url_or_data_or_function, options) {
  143 + var settings = $.extend({}, DEFAULT_SETTINGS, options || {});
  144 +
  145 + return this.each(function () {
  146 + $(this).data("settings", settings);
  147 + $(this).data("tokenInputObject", new $.TokenList(this, url_or_data_or_function, settings));
  148 + });
  149 + },
  150 + clear: function() {
  151 + this.data("tokenInputObject").clear();
  152 + return this;
  153 + },
  154 + add: function(item) {
  155 + this.data("tokenInputObject").add(item);
  156 + return this;
  157 + },
  158 + remove: function(item) {
  159 + this.data("tokenInputObject").remove(item);
  160 + return this;
  161 + },
  162 + get: function() {
  163 + return this.data("tokenInputObject").getTokens();
  164 + },
  165 + toggleDisabled: function(disable) {
  166 + this.data("tokenInputObject").toggleDisabled(disable);
  167 + return this;
  168 + },
  169 + setOptions: function(options){
  170 + $(this).data("settings", $.extend({}, $(this).data("settings"), options || {}));
  171 + return this;
  172 + },
  173 + destroy: function () {
  174 + if (this.data("tokenInputObject")) {
  175 + this.data("tokenInputObject").clear();
  176 + var tmpInput = this;
  177 + var closest = this.parent();
  178 + closest.empty();
  179 + tmpInput.show();
  180 + closest.append(tmpInput);
  181 + return tmpInput;
134 182 }
135   - } else if(typeof(url_or_data) === "object") {
136   - // Set the local data to search through
137   - settings.local_data = url_or_data;
138   - }
139   -
140   - // Build class names
141   - if(settings.classes) {
142   - // Use custom class names
143   - settings.classes = $.extend({}, DEFAULT_CLASSES, settings.classes);
144   - } else if(settings.theme) {
145   - // Use theme-suffixed default class names
146   - settings.classes = {};
147   - $.each(DEFAULT_CLASSES, function(key, value) {
148   - settings.classes[key] = value + "-" + settings.theme;
149   - });
150   - } else {
151   - settings.classes = DEFAULT_CLASSES;
152   - }
153   -
154   -
155   - // Save the tokens
156   - var saved_tokens = [];
157   -
158   - // Keep track of the number of tokens in the list
159   - var token_count = 0;
160   -
161   - // Basic cache to save on db hits
162   - var cache = new $.TokenList.Cache();
163   -
164   - // Keep track of the timeout, old vals
165   - var timeout;
166   - var input_val = '';
167   -
168   - // Create a new text input an attach keyup events
169   - var input_box = $("<input type=\"text\" autocomplete=\"off\">")
170   - .css({
171   - outline: "none"
172   - })
173   - .attr("id", settings.idPrefix + input.id)
174   - .focus(function () {
175   - if (settings.tokenLimit === null || settings.tokenLimit !== token_count) {
176   - if(settings.permanentDropdown || settings.showAllResults) {
177   - hide_dropdown_hint();
  183 + }
  184 + };
  185 +
  186 + // Expose the .tokenInput function to jQuery as a plugin
  187 + $.fn.tokenInput = function (method) {
  188 + // Method calling and initialization logic
  189 + if (methods[method]) {
  190 + return methods[method].apply(this, Array.prototype.slice.call(arguments, 1));
  191 + } else {
  192 + return methods.init.apply(this, arguments);
  193 + }
  194 + };
  195 +
  196 + // TokenList class for each input
  197 + $.TokenList = function (input, url_or_data, settings) {
  198 + //
  199 + // Initialization
  200 + //
  201 +
  202 + // Configure the data source
  203 + if (typeof(url_or_data) === "string" || typeof(url_or_data) === "function") {
  204 + // Set the url to query against
  205 + $(input).data("settings").url = url_or_data;
  206 +
  207 + // If the URL is a function, evaluate it here to do our initalization work
  208 + var url = computeURL();
  209 +
  210 + // Make a smart guess about cross-domain if it wasn't explicitly specified
  211 + if ($(input).data("settings").crossDomain === undefined && typeof url === "string") {
  212 + if(url.indexOf("://") === -1) {
  213 + $(input).data("settings").crossDomain = false;
  214 + } else {
  215 + $(input).data("settings").crossDomain = (location.href.split(/\/+/g)[1] !== url.split(/\/+/g)[1]);
  216 + }
  217 + }
  218 + } else if (typeof(url_or_data) === "object") {
  219 + // Set the local data to search through
  220 + $(input).data("settings").local_data = url_or_data;
  221 + }
  222 +
  223 + // Build class names
  224 + if($(input).data("settings").classes) {
  225 + // Use custom class names
  226 + $(input).data("settings").classes = $.extend({}, DEFAULT_CLASSES, $(input).data("settings").classes);
  227 + } else if($(input).data("settings").theme) {
  228 + // Use theme-suffixed default class names
  229 + $(input).data("settings").classes = {};
  230 + $.each(DEFAULT_CLASSES, function(key, value) {
  231 + $(input).data("settings").classes[key] = value + "-" + $(input).data("settings").theme;
  232 + });
  233 + } else {
  234 + $(input).data("settings").classes = DEFAULT_CLASSES;
  235 + }
  236 +
  237 + // Save the tokens
  238 + var saved_tokens = [];
  239 +
  240 + // Keep track of the number of tokens in the list
  241 + var token_count = 0;
  242 +
  243 + // Basic cache to save on db hits
  244 + var cache = new $.TokenList.Cache();
  245 +
  246 + // Keep track of the timeout, old vals
  247 + var timeout;
  248 + var input_val;
  249 +
  250 + // Create a new text input an attach keyup events
  251 + var input_box = $("<input type=\"text\" autocomplete=\"off\" autocapitalize=\"off\"/>")
  252 + .css({
  253 + outline: "none"
  254 + })
  255 + .attr("id", $(input).data("settings").idPrefix + input.id)
  256 + .focus(function () {
  257 + if ($(input).data("settings").disabled) {
  258 + return false;
178 259 } else
179   - show_dropdown_hint();
180   - if (settings.showAllResults)
181   - do_search();
182   - }
183   - })
184   - .blur(function () {
185   - if(settings.permanentDropdown)
186   - show_dropdown_hint();
187   - else {
  260 + if ($(input).data("settings").tokenLimit === null || $(input).data("settings").tokenLimit !== token_count) {
  261 + show_dropdown_hint();
  262 + }
  263 + token_list.addClass($(input).data("settings").classes.focused);
  264 + })
  265 + .blur(function () {
188 266 hide_dropdown();
189   - }
190   - })
191   - .bind("keyup keydown blur update", resize_input)
192   - .keydown(function (event) {
193   - var previous_token;
194   - var next_token;
195   -
196   - switch(event.keyCode) {
197   - case KEY.LEFT:
198   - case KEY.RIGHT:
199   - case KEY.UP:
200   - case KEY.DOWN:
201   - if(!$(this).val()) {
  267 +
  268 + if ($(input).data("settings").allowFreeTagging) {
  269 + add_freetagging_tokens();
  270 + }
  271 +
  272 + $(this).val("");
  273 + token_list.removeClass($(input).data("settings").classes.focused);
  274 + })
  275 + .bind("keyup keydown blur update", resize_input)
  276 + .keydown(function (event) {
  277 + var previous_token;
  278 + var next_token;
  279 +
  280 + switch(event.keyCode) {
  281 + case KEY.LEFT:
  282 + case KEY.RIGHT:
  283 + case KEY.UP:
  284 + case KEY.DOWN:
  285 + if(this.value.length === 0) {
202 286 previous_token = input_token.prev();
203 287 next_token = input_token.next();
204 288  
205   - if((previous_token.length && previous_token.get(0) === selected_token) || (next_token.length && next_token.get(0) === selected_token)) {
  289 + if((previous_token.length && previous_token.get(0) === selected_token) ||
  290 + (next_token.length && next_token.get(0) === selected_token)) {
206 291 // Check if there is a previous/next token and it is selected
207 292 if(event.keyCode === KEY.LEFT || event.keyCode === KEY.UP) {
208 293 deselect_token($(selected_token), POSITION.BEFORE);
... ... @@ -217,650 +302,805 @@ $.TokenList = function (input, url_or_data, options) {
217 302 select_token($(next_token.get(0)));
218 303 }
219 304 } else {
220   - var dropdown_item = null;
221   -
222   - if (event.keyCode == KEY.LEFT && (this.selectionStart > 0 || this.selectionStart != this.selectionEnd))
223   - return true;
224   - else if (event.keyCode == KEY.RIGHT && (this.selectionEnd < $(this).val().length || this.selectionStart != this.selectionEnd))
225   - return true;
226   - else if(event.keyCode === KEY.DOWN || event.keyCode === KEY.RIGHT) {
227   - dropdown_item = $(selected_dropdown_item).next();
228   - } else {
229   - dropdown_item = $(selected_dropdown_item).prev();
  305 + var dropdown_item = null;
  306 +
  307 + if (event.keyCode === KEY.DOWN || event.keyCode === KEY.RIGHT) {
  308 + dropdown_item = $(dropdown).find('li').first();
  309 +
  310 + if (selected_dropdown_item) {
  311 + dropdown_item = $(selected_dropdown_item).next();
230 312 }
  313 + } else {
  314 + dropdown_item = $(dropdown).find('li').last();
231 315  
232   - if(dropdown_item.length) {
233   - select_dropdown_item(dropdown_item);
  316 + if (selected_dropdown_item) {
  317 + dropdown_item = $(selected_dropdown_item).prev();
234 318 }
235   - return false;
  319 + }
  320 +
  321 + select_dropdown_item(dropdown_item);
236 322 }
  323 +
237 324 break;
238 325  
239   - case KEY.BACKSPACE:
240   - case KEY.DELETE:
241   - previous_token = input_token.prev();
242   - next_token = input_token.next();
  326 + case KEY.BACKSPACE:
  327 + previous_token = input_token.prev();
243 328  
244   - if(!$(this).val().length && settings.backspaceDeleteItem) {
245   - if(selected_token) {
246   - delete_token($(selected_token));
247   - input_box.focus();
248   - } else if(KEY.DELETE && next_token.length) {
249   - select_token($(next_token.get(0)));
250   - } else if(KEY.BACKSPACE && previous_token.length) {
251   - select_token($(previous_token.get(0)));
  329 + if (this.value.length === 0) {
  330 + if (selected_token) {
  331 + delete_token($(selected_token));
  332 + hiddenInput.change();
  333 + } else if(previous_token.length) {
  334 + select_token($(previous_token.get(0)));
252 335 }
253 336  
254 337 return false;
255   - } else if(!settings.permanentDropdown && $(this).val().length === 1) {
256   - hide_dropdown();
  338 + } else if($(this).val().length === 1) {
  339 + hide_dropdown();
  340 + } else {
  341 + // set a timeout just long enough to let this function finish.
  342 + setTimeout(function(){ do_search(); }, 5);
  343 + }
  344 + break;
  345 +
  346 + case KEY.TAB:
  347 + case KEY.ENTER:
  348 + case KEY.NUMPAD_ENTER:
  349 + case KEY.COMMA:
  350 + if(selected_dropdown_item) {
  351 + add_token($(selected_dropdown_item).data("tokeninput"));
  352 + hiddenInput.change();
257 353 } else {
258   - // set a timeout just long enough to let this function finish.
259   - setTimeout(function(){do_search();}, 5);
  354 + if ($(input).data("settings").allowFreeTagging) {
  355 + if($(input).data("settings").allowTabOut && $(this).val() === "") {
  356 + return true;
  357 + } else {
  358 + add_freetagging_tokens();
  359 + }
  360 + } else {
  361 + $(this).val("");
  362 + if($(input).data("settings").allowTabOut) {
  363 + return true;
  364 + }
  365 + }
  366 + event.stopPropagation();
  367 + event.preventDefault();
260 368 }
261   - break;
262   -
263   - case KEY.TAB:
264   - case KEY.ENTER:
265   - case KEY.NUMPAD_ENTER:
266   - case KEY.COMMA:
267   - if(selected_dropdown_item) {
268   - add_token($(selected_dropdown_item).data("tokeninput"));
269   - input_box.focus();
270 369 return false;
271   - }
272   - break;
273 370  
274   - case KEY.ESCAPE:
275   - hide_dropdown();
276   - return true;
  371 + case KEY.ESCAPE:
  372 + hide_dropdown();
  373 + return true;
277 374  
278   - default:
279   - if(String.fromCharCode(event.which)) {
280   - // set a timeout just long enough to let this function finish.
281   - setTimeout(function(){do_search();}, 5);
  375 + default:
  376 + if (String.fromCharCode(event.which)) {
  377 + // set a timeout just long enough to let this function finish.
  378 + setTimeout(function(){ do_search(); }, 5);
282 379 }
283 380 break;
284   - }
285   - });
286   -
287   - // Keep a reference to the original input box
288   - var hidden_input = $(input)
289   - .hide()
290   - .val("")
291   - .focus(function () {
292   - input_box.focus();
293   - })
294   - .blur(function () {
295   - input_box.blur();
296   - });
297   -
298   - // Keep a reference to the selected token and dropdown item
299   - var selected_token = null;
300   - var selected_token_index = 0;
301   - var selected_dropdown_item = null;
302   -
303   - // The list to store the token items in
304   - var token_list = $("<ul />")
305   - .addClass(settings.classes.tokenList)
306   - .click(function (event) {
307   - var li = $(event.target).closest("li");
308   - if(li && li.get(0) && $.data(li.get(0), "tokeninput")) {
309   - toggle_select_token(li);
310   - } else {
311   - // Deselect selected token
312   - if(selected_token) {
313   - deselect_token($(selected_token), POSITION.END);
314   - }
315   -
316   - // Transfer focus
317   - if (!input_box.is(':focus'))
318   - input_box.focus();
319   - }
  381 + }
  382 + });
  383 +
  384 + // Keep reference for placeholder
  385 + if (settings.placeholder) {
  386 + input_box.attr("placeholder", settings.placeholder);
  387 + }
  388 +
  389 + // Keep a reference to the original input box
  390 + var hiddenInput = $(input)
  391 + .hide()
  392 + .val("")
  393 + .focus(function () {
  394 + focusWithTimeout(input_box);
320 395 })
321   - .mouseover(function (event) {
322   - var li = $(event.target).closest("li");
323   - if(li && selected_token !== this) {
324   - li.addClass(settings.classes.highlightedToken);
325   - }
  396 + .blur(function () {
  397 + input_box.blur();
  398 +
  399 + //return the object to this can be referenced in the callback functions.
  400 + return hiddenInput;
326 401 })
327   - .mouseout(function (event) {
328   - var li = $(event.target).closest("li");
329   - if(li && selected_token !== this) {
330   - li.removeClass(settings.classes.highlightedToken);
  402 + ;
  403 +
  404 + // Keep a reference to the selected token and dropdown item
  405 + var selected_token = null;
  406 + var selected_token_index = 0;
  407 + var selected_dropdown_item = null;
  408 +
  409 + // The list to store the token items in
  410 + var token_list = $("<ul />")
  411 + .addClass($(input).data("settings").classes.tokenList)
  412 + .click(function (event) {
  413 + var li = $(event.target).closest("li");
  414 + if(li && li.get(0) && $.data(li.get(0), "tokeninput")) {
  415 + toggle_select_token(li);
  416 + } else {
  417 + // Deselect selected token
  418 + if(selected_token) {
  419 + deselect_token($(selected_token), POSITION.END);
  420 + }
  421 +
  422 + // Focus input box
  423 + focusWithTimeout(input_box);
  424 + }
  425 + })
  426 + .mouseover(function (event) {
  427 + var li = $(event.target).closest("li");
  428 + if(li && selected_token !== this) {
  429 + li.addClass($(input).data("settings").classes.highlightedToken);
  430 + }
  431 + })
  432 + .mouseout(function (event) {
  433 + var li = $(event.target).closest("li");
  434 + if(li && selected_token !== this) {
  435 + li.removeClass($(input).data("settings").classes.highlightedToken);
  436 + }
  437 + })
  438 + .insertBefore(hiddenInput);
  439 +
  440 + // The token holding the input box
  441 + var input_token = $("<li />")
  442 + .addClass($(input).data("settings").classes.inputToken)
  443 + .appendTo(token_list)
  444 + .append(input_box);
  445 +
  446 + // The list to store the dropdown items in
  447 + var dropdown = $("<div/>")
  448 + .addClass($(input).data("settings").classes.dropdown)
  449 + .appendTo("body")
  450 + .hide();
  451 +
  452 + // Magic element to help us resize the text input
  453 + var input_resizer = $("<tester/>")
  454 + .insertAfter(input_box)
  455 + .css({
  456 + position: "absolute",
  457 + top: -9999,
  458 + left: -9999,
  459 + width: "auto",
  460 + fontSize: input_box.css("fontSize"),
  461 + fontFamily: input_box.css("fontFamily"),
  462 + fontWeight: input_box.css("fontWeight"),
  463 + letterSpacing: input_box.css("letterSpacing"),
  464 + whiteSpace: "nowrap"
  465 + });
  466 +
  467 + // Pre-populate list if items exist
  468 + hiddenInput.val("");
  469 + var li_data = $(input).data("settings").prePopulate || hiddenInput.data("pre");
  470 +
  471 + if ($(input).data("settings").processPrePopulate && $.isFunction($(input).data("settings").onResult)) {
  472 + li_data = $(input).data("settings").onResult.call(hiddenInput, li_data);
  473 + }
  474 +
  475 + if (li_data && li_data.length) {
  476 + $.each(li_data, function (index, value) {
  477 + insert_token(value);
  478 + checkTokenLimit();
  479 + input_box.attr("placeholder", null)
  480 + });
  481 + }
  482 +
  483 + // Check if widget should initialize as disabled
  484 + if ($(input).data("settings").disabled) {
  485 + toggleDisabled(true);
  486 + }
  487 +
  488 + // Initialization is done
  489 + if (typeof($(input).data("settings").onReady) === "function") {
  490 + $(input).data("settings").onReady.call();
  491 + }
  492 +
  493 + //
  494 + // Public functions
  495 + //
  496 +
  497 + this.clear = function() {
  498 + token_list.children("li").each(function() {
  499 + if ($(this).children("input").length === 0) {
  500