diff --git a/.gitignore b/.gitignore
index e2315a6459..d17d2f5c15 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,7 +8,6 @@ config/*.yml
.rspec
spec/reports
coverage/*
-db/schema.rb
db/*.sql*
log/*.log
diff --git a/Gemfile b/Gemfile
index 7e63d9d51e..7e04ca44e5 100644
--- a/Gemfile
+++ b/Gemfile
@@ -11,6 +11,7 @@ gem "pg", ">= 0.9.0"
gem 'authlogic', '~> 3.0.3'
gem 'acts_as_commentable', '>= 3.0.1'
+gem 'acts-as-taggable-on', '>= 2.0.6'
gem 'haml', '>= 3.1.1'
gem 'sass', '>= 3.1.1'
gem 'paperclip', '~> 2.3.6'
@@ -18,6 +19,8 @@ gem 'will_paginate', '>= 3.0.pre2'
gem 'acts_as_list', '~> 0.1.4'
gem 'simple_form', '~> 1.5.2'
#~ gem 'jquery-rails' TODO: Go to rails 3.1
+gem 'ffaker', '>= 1.5.0' # For demo data
+
group :development, :test do
gem 'ruby-debug', :platform => :mri_18
@@ -28,14 +31,13 @@ group :development, :test do
gem 'test-unit', '1.2.3', :platform => :mri_19
gem "rspec-rails", '>= 2.5.0'
gem 'ffaker', '>= 1.5.0'
- gem 'factory_girl', '~> 1.3.3'
+ gem 'factory_girl', '>= 1.3.3'
end
group :test do
gem 'factory_girl_rails', '~> 1.0.1'
end
-
# Gem watch list:
#---------------------------------------------------------------------
# gem 'authlogic', :git => 'git://github.com/crossroads/authlogic.git', :branch => 'rails3'
@@ -51,4 +53,3 @@ end
# is_paranoid, git://github.com/theshortcut/is_paranoid.git
# prototype_legacy_helper, git://github.com/rails/prototype_legacy_helper.git
# responds_to_parent, git://github.com/markcatley/responds_to_parent.git
-
diff --git a/Gemfile.lock b/Gemfile.lock
index 6d169fb4ce..e197699792 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -28,6 +28,8 @@ GEM
activemodel (= 3.0.7)
activesupport (= 3.0.7)
activesupport (3.0.7)
+ acts-as-taggable-on (2.1.1)
+ rails
acts_as_commentable (3.0.1)
acts_as_list (0.1.4)
annotate (2.4.0)
@@ -134,12 +136,13 @@ PLATFORMS
ruby
DEPENDENCIES
+ acts-as-taggable-on (>= 2.0.6)
acts_as_commentable (>= 3.0.1)
acts_as_list (~> 0.1.4)
annotate (>= 2.4.0)
authlogic (~> 3.0.3)
awesome_print (>= 0.3.1)
- factory_girl (~> 1.3.3)
+ factory_girl (>= 1.3.3)
factory_girl_rails (~> 1.0.1)
ffaker (>= 1.5.0)
haml (>= 3.1.1)
diff --git a/Rakefile b/Rakefile
index 48095014a4..cdc9dc4a36 100644
--- a/Rakefile
+++ b/Rakefile
@@ -15,8 +15,9 @@ namespace :spec do
tmp_env = Rails.env
Rails.env = "test"
Rake::Task["crm:copy_default_config"].invoke
+ puts "Running initial migrations..."
puts "Preparing test database..."
- Rake::Task["db:test:prepare"].invoke
+ Rake::Task["db:schema:load"].invoke
Rails.env = tmp_env
end
end
diff --git a/app/controllers/admin/fields_controller.rb b/app/controllers/admin/fields_controller.rb
index 802660fa00..cfc61359c5 100644
--- a/app/controllers/admin/fields_controller.rb
+++ b/app/controllers/admin/fields_controller.rb
@@ -121,12 +121,10 @@ def update
# DELETE /fields/1.xml HTML and AJAX
#----------------------------------------------------------------------------
def destroy
- @custom_field = CustomField.find(params[:id])
- @custom_field.destroy if @custom_field
+ @field = CustomField.find(params[:id])
respond_to do |format|
- format.html { respond_to_destroy(:html) }
- format.js { respond_to_destroy(:ajax) }
+ format.js # destroy.js.rjs
format.xml { head :ok }
end
@@ -137,27 +135,4 @@ def destroy
# POST /fields/auto_complete/query AJAX
#----------------------------------------------------------------------------
# Handled by before_filter :auto_complete, :only => :auto_complete
-
- private
-
- #----------------------------------------------------------------------------
- def respond_to_destroy(method)
- if method == :ajax
- if called_from_index_page?
- @fields = get_fields
- if @fields.blank?
- @fields = get_fields(:page => current_page - 1) if current_page > 1
- render :action => :index and return
- end
- else
- self.current_page = 1
- end
- # At this point render destroy.js.rjs
- else
- self.current_page = 1
- flash[:notice] = "#{@custom_field.field_name} has beed deleted."
- redirect_to(fields_path)
- end
- end
end
-
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index ef95811cd3..b8caedfa09 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -83,6 +83,15 @@ def timeline(asset)
(asset.comments + asset.emails).sort { |x, y| y.created_at <=> x.created_at }
end
+ # Controller instance method that responds to /controlled/tagged/tag request.
+ # It stores given tag as current query and redirect to index to display all
+ # records tagged with the tag.
+ #----------------------------------------------------------------------------
+ def tagged
+ self.send(:current_query=, "#" << params[:id]) unless params[:id].blank?
+ redirect_to :action => "index"
+ end
+
private
#----------------------------------------------------------------------------
def set_context
@@ -227,8 +236,9 @@ def respond_to_related_not_found(related, *types)
#----------------------------------------------------------------------------
def get_list_of_records(klass, options = {})
items = klass.name.tableize
- self.current_page = options[:page] if options[:page]
- self.current_query = options[:query] if options[:query]
+ self.current_page = options[:page] if options[:page]
+ query, tags = parse_query_and_tags(options[:query]) if options[:query]
+ self.current_query = query
records = {
:user => @current_user,
@@ -250,10 +260,11 @@ def get_list_of_records(klass, options = {})
filter = session[options[:filter]].to_s.split(',') if options[:filter]
scope = klass.my(records)
- scope = scope.state(filter) unless filter.blank?
- scope = scope.search(current_query) unless current_query.blank?
- scope = scope.unscoped if wants.csv?
- scope = scope.paginate(pages) if wants.html? || wants.js? || wants.xml?
+ scope = scope.state(filter) if filter.present?
+ scope = scope.search(query) if query.present?
+ scope = scope.tagged_with(tags, :on => :tags) if tags.present?
+ scope = scope.unscoped if wants.csv?
+ scope = scope.paginate(pages) if wants.html? || wants.js? || wants.xml?
scope
end
@@ -280,5 +291,20 @@ def current_query
@current_query = params[:query] || session["#{controller_name}_current_query".to_sym] || ""
end
+ # Somewhat simplistic parser that extracts query and hash-prefixed tags from
+ # the search string and returns them as two element array, for example:
+ #
+ # "#real Billy Bones #pirate" => [ "Billy Bones", "real, pirate" ]
+ #----------------------------------------------------------------------------
+ def parse_query_and_tags(search_string)
+ query, tags = [], []
+ search_string.scan(/[\w@\-\.#]+/).each do |token|
+ if token.starts_with?("#")
+ tags << token[1 .. -1]
+ else
+ query << token
+ end
+ end
+ [ query.join(" "), tags.join(", ") ]
+ end
end
-
diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb
new file mode 100755
index 0000000000..03023ff05b
--- /dev/null
+++ b/app/helpers/tags_helper.rb
@@ -0,0 +1,49 @@
+# Fat Free CRM
+# Copyright (C) 2008-2011 by Michael Dvorkin
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#------------------------------------------------------------------------------
+
+module TagsHelper
+
+ # Generate tag links for use on asset index pages.
+ #----------------------------------------------------------------------------
+ def tags_for_index(model)
+ model.tag_list.inject([]) do |arr, tag|
+ query = controller.send(:current_query) || ""
+ hashtag = "##{tag}"
+ if query.empty?
+ query = hashtag
+ elsif !query.include?(hashtag)
+ query += " #{hashtag}"
+ end
+ arr << link_to_function(tag, "crm.search_tagged('#{query}', '#{model.class.to_s.tableize}')", :title => tag)
+ end.join(" ").html_safe
+ end
+
+ # Generate tag links for the asset landing page (shown on a sidebar).
+ #----------------------------------------------------------------------------
+ def tags_for_show(model)
+ model.tag_list.inject([]) do |arr, tag|
+ arr << link_to(tag, url_for(:action => "tagged", :id => tag), :title => tag)
+ end.join(" ").html_safe
+ end
+
+ # Return asset tags to be built manually if the asset failed validation.
+ def unsaved_param_tags(asset)
+ params[asset][:tag_list].split(",").map {|x|
+ ActsAsTaggableOn::Tag.find_by_name(x.strip)
+ }.compact.uniq
+ end
+end
diff --git a/app/models/core/account.rb b/app/models/core/account.rb
index 775b9f80d2..c15a0864ff 100644
--- a/app/models/core/account.rb
+++ b/app/models/core/account.rb
@@ -64,6 +64,7 @@ class Account < ActiveRecord::Base
uses_user_permissions
acts_as_commentable
+ acts_as_taggable_on :tags
is_paranoid
has_fields
exportable
diff --git a/app/models/core/campaign.rb b/app/models/core/campaign.rb
index e242dcc46f..c2d66ec7bc 100644
--- a/app/models/core/campaign.rb
+++ b/app/models/core/campaign.rb
@@ -63,6 +63,7 @@ class Campaign < ActiveRecord::Base
uses_user_permissions
acts_as_commentable
+ acts_as_taggable_on :tags
is_paranoid
has_fields
exportable
diff --git a/app/models/core/contact.rb b/app/models/core/contact.rb
index 460b601834..644f2dffa2 100644
--- a/app/models/core/contact.rb
+++ b/app/models/core/contact.rb
@@ -85,6 +85,7 @@ class Contact < ActiveRecord::Base
uses_user_permissions
acts_as_commentable
+ acts_as_taggable_on :tags
is_paranoid
has_fields
exportable
diff --git a/app/models/core/lead.rb b/app/models/core/lead.rb
index 944e56b426..96922a60f6 100644
--- a/app/models/core/lead.rb
+++ b/app/models/core/lead.rb
@@ -75,6 +75,7 @@ class Lead < ActiveRecord::Base
uses_user_permissions
acts_as_commentable
+ acts_as_taggable_on :tags
is_paranoid
has_fields
exportable
diff --git a/app/models/core/opportunity.rb b/app/models/core/opportunity.rb
index 56b1cdec33..627acbd06c 100644
--- a/app/models/core/opportunity.rb
+++ b/app/models/core/opportunity.rb
@@ -71,6 +71,7 @@ class Opportunity < ActiveRecord::Base
uses_user_permissions
acts_as_commentable
+ acts_as_taggable_on :tags
is_paranoid
has_fields
exportable
diff --git a/app/stylesheets/common.sass b/app/stylesheets/common.sass
index 98f0d9364d..3b5a1f21ea 100644
--- a/app/stylesheets/common.sass
+++ b/app/stylesheets/common.sass
@@ -644,3 +644,14 @@ span.handle img
vertical-align: middle
margin-right: 5px
+.tags, .list li dt .tags
+ a:link, a:visited
+ :background lightsteelblue
+ :color white
+ :font-weight normal
+ :padding 0px 6px 1px 6px
+ :-moz-border-radius 8px
+ :-webkit-border-radius 8px
+ a:hover
+ :background steelblue
+ :color yellow
diff --git a/app/views/accounts/_account.html.haml b/app/views/accounts/_account.html.haml
index ff02586c11..f43cb6b724 100644
--- a/app/views/accounts/_account.html.haml
+++ b/app/views/accounts/_account.html.haml
@@ -21,11 +21,15 @@
= t('pluralize.contact', account.contacts.count) << " | "
= t('pluralize.opportunity', account.opportunities.count)
-
- unless @current_user.preference[:accounts_outline] == "brief"
%dt
= stars_for(account) + " | "
= link_to(account.website, account.website.to_url) << " | " if account.website.present?
= link_to_email(account.email) << " | " if account.email.present?
= t(:phone_small) << ": " << (account.toll_free_phone || account.phone) if account.toll_free_phone? || account.phone?
+
+ - if account.tag_list.present?
+ %dt
+ .tags= tags_for_index(account)
+
= hook(:account_bottom, self, :account => account)
diff --git a/app/views/accounts/_sidebar_show.html.haml b/app/views/accounts/_sidebar_show.html.haml
index a8e1dee58b..eb47526135 100644
--- a/app/views/accounts/_sidebar_show.html.haml
+++ b/app/views/accounts/_sidebar_show.html.haml
@@ -48,4 +48,8 @@
.caption #{t :background_info}
= auto_link(simple_format h(@account.background_info))
+ - if @account.tag_list.present?
+ %dt
+ .tags= tags_for_index(@account)
+
= hook(:show_account_sidebar_bottom, self, :account => @account)
diff --git a/app/views/accounts/_top_section.html.haml b/app/views/accounts/_top_section.html.haml
index 66137c0bfc..c3fb4689d0 100644
--- a/app/views/accounts/_top_section.html.haml
+++ b/app/views/accounts/_top_section.html.haml
@@ -25,5 +25,6 @@
.label= t(:background_info) << ':'
= f.text_area :background_info, :style =>"width:500px", :rows => 3
- = hook(:account_top_section_bottom, self, :f => f)
+ = render :partial => "/shared/tags", :locals => {:f => f, :span => 3}
+ = hook(:account_top_section_bottom, self, :f => f)
diff --git a/app/views/campaigns/_campaign.html.haml b/app/views/campaigns/_campaign.html.haml
index 15419bc5ae..b7fb94ba9f 100644
--- a/app/views/campaigns/_campaign.html.haml
+++ b/app/views/campaigns/_campaign.html.haml
@@ -10,4 +10,8 @@
= render "campaigns/status", :campaign => campaign
- unless @current_user.preference[:campaigns_outline] == "brief"
= render "campaigns/metrics", :campaign => campaign
+ - if campaign.tag_list.present?
+ %dt
+ .tags= tags_for_index(campaign)
+
= hook(:campaign_bottom, self, :campaign => campaign)
diff --git a/app/views/campaigns/_sidebar_show.html.haml b/app/views/campaigns/_sidebar_show.html.haml
index 30f94cc70a..31946cbb78 100644
--- a/app/views/campaigns/_sidebar_show.html.haml
+++ b/app/views/campaigns/_sidebar_show.html.haml
@@ -14,19 +14,19 @@
%tt #{t :budget}:
.caption #{t :campaign_targets}
-
+
-# Target Leads.
-#---------------------------------------------------------------------------
%li
%dt= @campaign.target_leads || t(:n_a)
%tt #{t :leads}:
-
+
-# Target Conversion Ratio.
-#---------------------------------------------------------------------------
%li
%dt= number_to_percentage(@campaign.target_conversion, :precision => 1) || t(:n_a)
%tt #{t :conversion}:
-
+
-# Target Opportunities: calculated based on target number of leads and
-# expected conversion ratio.
-#---------------------------------------------------------------------------
@@ -82,4 +82,8 @@
.caption #{t :background_info}
= auto_link(simple_format h(@campaign.background_info))
+ - if @campaign.tag_list.present?
+ %dt
+ .tags= tags_for_index(@campaign)
+
= hook(:show_campaign_sidebar_bottom, self, :campaign => @campaign)
diff --git a/app/views/campaigns/_top_section.html.haml b/app/views/campaigns/_top_section.html.haml
index 6815eabecf..36f04acaa1 100644
--- a/app/views/campaigns/_top_section.html.haml
+++ b/app/views/campaigns/_top_section.html.haml
@@ -25,4 +25,6 @@
.label= t(:background_info) << ':'
= f.text_area :background_info, :style =>"width:500px", :rows => 3
+ = render :partial => "/shared/tags", :locals => {:f => f, :span => 5}
+
= hook(:campaign_top_section_bottom, self, :f => f)
diff --git a/app/views/contacts/_contact.html.haml b/app/views/contacts/_contact.html.haml
index fa298949a4..ac6046ffea 100644
--- a/app/views/contacts/_contact.html.haml
+++ b/app/views/contacts/_contact.html.haml
@@ -27,4 +27,8 @@
= "#{t :phone_small}: ".html_safe + h(contact.phone) << " | ".html_safe if contact.phone.present?
= "#{t :mobile_small}: ".html_safe + h(contact.mobile) << " | ".html_safe if contact.mobile.present?
= t(:added_ago, time_ago_in_words(contact.created_at))
+ - if contact.tag_list.present?
+ %dt
+ .tags= tags_for_index(contact)
+
= hook(:contact_bottom, self, :contact => contact)
diff --git a/app/views/contacts/_sidebar_show.html.haml b/app/views/contacts/_sidebar_show.html.haml
index 2537186f9d..86e38dec4c 100644
--- a/app/views/contacts/_sidebar_show.html.haml
+++ b/app/views/contacts/_sidebar_show.html.haml
@@ -32,4 +32,8 @@
.caption #{t :background_info}
= auto_link(simple_format h(@contact.background_info))
+ - if @contact.tag_list.present?
+ %dt
+ .tags= tags_for_index(@contact)
+
= hook(:show_contact_sidebar_bottom, self, :contact => @contact)
diff --git a/app/views/contacts/_top_section.html.haml b/app/views/contacts/_top_section.html.haml
index 9474d28a41..c7da3bfe46 100644
--- a/app/views/contacts/_top_section.html.haml
+++ b/app/views/contacts/_top_section.html.haml
@@ -51,4 +51,6 @@
.label= t(:background_info) << ':'
= f.text_area :background_info, :style =>"width:500px", :rows => 3
+ = render :partial => "/shared/tags", :locals => {:f => f, :span => 3}
+
= hook(:contact_top_section_bottom, self, :f => f)
diff --git a/app/views/layouts/_tabbed.html.haml b/app/views/layouts/_tabbed.html.haml
index f869f410a4..823806ab58 100644
--- a/app/views/layouts/_tabbed.html.haml
+++ b/app/views/layouts/_tabbed.html.haml
@@ -4,7 +4,9 @@
%li
= link_to(tab[:url], :class => tab[:active] ? "active" : "") do
- unless request.fullpath.include?("/admin")
- = image_tag("tab_icons/#{tab[:text].to_s.sub(/^tab_/, '')}#{tab[:active] ? "_active" : ""}.png")
+ - img_base_path = "tab_icons/#{tab[:text].to_s.downcase.sub(/^tab_/, '')}"
+ - if File.exists?(Rails.root.join("public/images/#{img_base_path}.png"))
+ = image_tag("#{img_base_path}#{tab[:active] ? "_active" : ""}.png")
= t(tab[:text])
= show_flash
@@ -14,3 +16,4 @@
= render "layouts/sidebar"
%td{ :class => :main, :id => :main, :valign => :top }
= yield
+
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index c3d15bb6dd..387837ff68 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -4,15 +4,16 @@
%meta{ "http-equiv" => "Content-Type", :content => "text/html; charset=utf-8" }
%title Fat Free CRM
==
- = stylesheet_link_tag "screen", "modalbox.css"
+ = stylesheet_link_tag "screen", 'modalbox.css', 'facebooklist.css'
= stylesheet_link_tag "print", :media => "print"
- unless tabless_layout?
= stylesheet_link_tag "calendar_date_select/default.css"
%style= content_for :styles
- = javascript_include_tag :defaults, "modalbox.js", :cache => "cache/all"
+ = javascript_include_tag :defaults, 'modalbox.js', 'facebooklist.js', 'facebooklist.simulate.js', :cache => "cache/all"
- unless tabless_layout?
= javascript_include_tag "crm_classes.js", "calendar_date_select/calendar_date_select.js", "calendar_date_select/format_#{t(:calendar_date_select_format, :default => 'american')}.js", :cache => "cache/classes"
+
= hook(:javascript_includes, self)
diff --git a/app/views/leads/_lead.html.haml b/app/views/leads/_lead.html.haml
index 0f33fe06eb..b3e0d49d5e 100644
--- a/app/views/leads/_lead.html.haml
+++ b/app/views/leads/_lead.html.haml
@@ -36,5 +36,8 @@
= "#{t :phone_small}: " + lead.phone << " | " if lead.phone.present?
= "#{t :mobile_small}: " + lead.mobile << " | " if lead.mobile.present?
= t(:added_ago, time_ago_in_words(lead.created_at))
- = hook(:lead_bottom, self, :lead => lead)
+ - if lead.tag_list.present?
+ %dt
+ .tags= tags_for_index(lead)
+ = hook(:lead_bottom, self, :lead => lead)
diff --git a/app/views/leads/_sidebar_show.html.haml b/app/views/leads/_sidebar_show.html.haml
index 2fbf763a15..2e45334d93 100644
--- a/app/views/leads/_sidebar_show.html.haml
+++ b/app/views/leads/_sidebar_show.html.haml
@@ -49,4 +49,8 @@
.caption #{t :background_info}
= auto_link(simple_format h(@lead.background_info))
+ - if @lead.tag_list.present?
+ %dt
+ .tags= tags_for_index(@lead)
+
= hook(:show_lead_sidebar_bottom, self, :lead => @lead)
diff --git a/app/views/leads/_top_section.html.haml b/app/views/leads/_top_section.html.haml
index fd55c1b5a0..5e4a483129 100644
--- a/app/views/leads/_top_section.html.haml
+++ b/app/views/leads/_top_section.html.haml
@@ -1,3 +1,4 @@
+
= hook(:lead_top_section, self, :f => f) do
.section
%table
@@ -24,4 +25,6 @@
.label= t(:background_info) << ':'
= f.text_area :background_info, :style =>"width:500px", :rows => 3
+ = render :partial => "/shared/tags", :locals => {:f => f, :span => 3}
+
= hook(:lead_top_section_bottom, self, :f => f)
diff --git a/app/views/opportunities/_opportunity.html.haml b/app/views/opportunities/_opportunity.html.haml
index 2bbd16b9fd..18f90c9048 100644
--- a/app/views/opportunities/_opportunity.html.haml
+++ b/app/views/opportunities/_opportunity.html.haml
@@ -23,7 +23,7 @@
- unless won_or_lost
== #{number_to_currency(opportunity.amount || 0, :precision => 0)}
== #{opportunity.discount ? t(:discount_number, number_to_currency(opportunity.discount, :precision => 0)) : t(:no_discount)}
- = t(:probability_number, (opportunity.probability || 0).to_s + '%') + " | "
+ = t(:probability_number, (opportunity.probability || 0).to_s + '%') + " | "
- if opportunity.closes_on
- if won_or_lost
- if opportunity.closes_on >= Date.today
@@ -38,4 +38,8 @@
%span.warn= t(:past_due, distance_of_time_in_words(opportunity.closes_on, Date.today))
- else
= t(:no_closing_date)
+ - if opportunity.tag_list.present?
+ %dt
+ .tags= tags_for_index(opportunity)
+
= hook(:opportunity_bottom, self, :opportunity => opportunity)
diff --git a/app/views/opportunities/_sidebar_show.html.haml b/app/views/opportunities/_sidebar_show.html.haml
index 2f335b3253..a12b8e55bd 100644
--- a/app/views/opportunities/_sidebar_show.html.haml
+++ b/app/views/opportunities/_sidebar_show.html.haml
@@ -52,4 +52,8 @@
.caption #{t :background_info}
= auto_link(simple_format h(@opportunity.background_info))
+ - if @opportunity.tag_list.present?
+ %dt
+ .tags= tags_for_index(@opportunity)
+
= hook(:show_opportunity_sidebar_bottom, self, :opportunity => @opportunity)
diff --git a/app/views/opportunities/_top_section.html.haml b/app/views/opportunities/_top_section.html.haml
index 37f9e448c5..6654048df7 100644
--- a/app/views/opportunities/_top_section.html.haml
+++ b/app/views/opportunities/_top_section.html.haml
@@ -1,3 +1,4 @@
+
= hook(:opportunity_top_section, self, :f => f) do
.section
%table
@@ -60,4 +61,6 @@
.label= t(:background_info) << ':'
= f.text_area :background_info, :style =>"width:500px", :rows => 3
+ = render :partial => "/shared/tags", :locals => {:f => f, :span => 3}
+
= hook(:opportunity_top_section_bottom, self, :f => f)
diff --git a/app/views/shared/_tags.html.haml b/app/views/shared/_tags.html.haml
new file mode 100644
index 0000000000..512488a7a0
--- /dev/null
+++ b/app/views/shared/_tags.html.haml
@@ -0,0 +1,22 @@
+- asset = controller_name.singularize
+- f.object.tags = unsaved_param_tags(asset) if params[asset] && params[asset][:tag_list]
+%tr
+ %td{ :valign => :top, :colspan => span }
+ .label.req Tags: (comma separated, letters and digits only)
+ #taggings
+ #facebook-list
+ = f.text_field :tag_list, :id => "tag_list", :style => "width:500px", :autocomplete => "off"
+ #facebook-auto
+ .default Type the name of a tag you'd like to use. Use commas to separate multiple tags.
+ %ul.feed
+ - # Get tags from the object.
+ - f.object.tags.map{|t| t.name }.each do |tag|
+ %li{ :value => tag }= tag
+ :javascript
+ fbtaglist = new FacebookList('tag_list', 'facebook-auto',
+ { newValues: true,
+ regexSearch: false,
+ separator: Event.KEY_COMMA });
+ var tagjson = #{ActsAsTaggableOn::Tag.all.map{|t| {"caption" => t.name, "value" => t.name} }.to_json}
+ tagjson.each(function(t){fbtaglist.autoFeed(t)});
+
diff --git a/config/initializers/require_models.rb b/config/initializers/require_models.rb
new file mode 100644
index 0000000000..8267549c15
--- /dev/null
+++ b/config/initializers/require_models.rb
@@ -0,0 +1,9 @@
+#
+# We need to require our models here, since they are organized in subdirectories.
+# If we don't do this, is_paranoid raises the following error when running tests:
+#
+# super from singleton method that is defined to multiple classes is not supported;
+# this will be fixed in 1.9.3 or later
+#
+Dir[Rails.root.join("app/models/**/*.rb")].each {|f| require f }
+
diff --git a/config/routes.rb b/config/routes.rb
index edb6842e9f..4c01127553 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -11,6 +11,7 @@
match 'signup' => 'users#new', :as => :signup
match 'timeline' => 'home#timeline', :as => :timeline
match 'timezone' => 'home#timezone', :as => :timezone
+ match 'redraw' => 'home#redraw', :as => :redraw
match 'toggle' => 'home#toggle'
resource :authentication
@@ -20,6 +21,7 @@
resources :accounts do
collection do
+ post :filter
get :options
get :search
post :auto_complete
@@ -33,7 +35,7 @@
resources :campaigns do
collection do
- get :filter
+ post :filter
get :options
get :search
post :auto_complete
@@ -47,6 +49,7 @@
resources :contacts do
collection do
+ post :filter
get :options
get :search
post :auto_complete
@@ -60,7 +63,7 @@
resources :leads do
collection do
- get :filter
+ post :filter
get :options
get :search
post :auto_complete
@@ -77,7 +80,7 @@
resources :opportunities do
collection do
- get :filter
+ post :filter
get :options
get :search
post :auto_complete
@@ -91,7 +94,7 @@
resources :tasks do
collection do
- get :filter
+ post :filter
post :auto_complete
end
member do
@@ -137,7 +140,7 @@
resources :plugins
end
- match '/:controller(/:action(/:id))'
+ get '/:controller/tagged/:id' => '#tagged'
end
end
diff --git a/db/schema.rb b/db/schema.rb
new file mode 100644
index 0000000000..34b4699c8f
--- /dev/null
+++ b/db/schema.rb
@@ -0,0 +1,399 @@
+# This file is auto-generated from the current state of the database. Instead
+# of editing this file, please use the migrations feature of Active Record to
+# incrementally modify your database, and then regenerate this schema definition.
+#
+# Note that this schema.rb definition is the authoritative source for your
+# database schema. If you need to create the application database on another
+# system, you should be using db:schema:load, not running all the migrations
+# from scratch. The latter is a flawed and unsustainable approach (the more migrations
+# you'll amass, the slower it'll run and the greater likelihood for issues).
+#
+# It's strongly recommended to check this file into your version control system.
+
+ActiveRecord::Schema.define(:version => 20111101090312) do
+
+ create_table "account_contacts", :force => true do |t|
+ t.integer "account_id"
+ t.integer "contact_id"
+ t.datetime "deleted_at"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ end
+
+ create_table "account_opportunities", :force => true do |t|
+ t.integer "account_id"
+ t.integer "opportunity_id"
+ t.datetime "deleted_at"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ end
+
+ create_table "accounts", :force => true do |t|
+ t.integer "user_id"
+ t.integer "assigned_to"
+ t.string "name", :limit => 64, :default => "", :null => false
+ t.string "access", :limit => 8, :default => "Public"
+ t.string "website", :limit => 64
+ t.string "toll_free_phone", :limit => 32
+ t.string "phone", :limit => 32
+ t.string "fax", :limit => 32
+ t.datetime "deleted_at"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ t.string "email", :limit => 64
+ t.string "background_info"
+ t.integer "rating", :default => 0, :null => false
+ t.string "category", :limit => 32
+ end
+
+ add_index "accounts", ["assigned_to"], :name => "index_accounts_on_assigned_to"
+ add_index "accounts", ["user_id", "name", "deleted_at"], :name => "index_accounts_on_user_id_and_name_and_deleted_at", :unique => true
+
+ create_table "activities", :force => true do |t|
+ t.integer "user_id"
+ t.integer "subject_id"
+ t.string "subject_type"
+ t.string "action", :limit => 32, :default => "created"
+ t.string "info", :default => ""
+ t.boolean "private", :default => false
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ end
+
+ add_index "activities", ["created_at"], :name => "index_activities_on_created_at"
+ add_index "activities", ["user_id"], :name => "index_activities_on_user_id"
+
+ create_table "addresses", :force => true do |t|
+ t.string "street1"
+ t.string "street2"
+ t.string "city", :limit => 64
+ t.string "state", :limit => 64
+ t.string "zipcode", :limit => 16
+ t.string "country", :limit => 64
+ t.string "full_address"
+ t.string "address_type", :limit => 16
+ t.integer "addressable_id"
+ t.string "addressable_type"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ t.datetime "deleted_at"
+ end
+
+ add_index "addresses", ["addressable_id", "addressable_type"], :name => "index_addresses_on_addressable_id_and_addressable_type"
+
+ create_table "avatars", :force => true do |t|
+ t.integer "user_id"
+ t.integer "entity_id"
+ t.string "entity_type"
+ t.integer "image_file_size"
+ t.string "image_file_name"
+ t.string "image_content_type"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ end
+
+ create_table "campaigns", :force => true do |t|
+ t.integer "user_id"
+ t.integer "assigned_to"
+ t.string "name", :limit => 64, :default => "", :null => false
+ t.string "access", :limit => 8, :default => "Public"
+ t.string "status", :limit => 64
+ t.decimal "budget", :precision => 12, :scale => 2
+ t.integer "target_leads"
+ t.float "target_conversion"
+ t.decimal "target_revenue", :precision => 12, :scale => 2
+ t.integer "leads_count"
+ t.integer "opportunities_count"
+ t.decimal "revenue", :precision => 12, :scale => 2
+ t.date "starts_on"
+ t.date "ends_on"
+ t.text "objectives"
+ t.datetime "deleted_at"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ t.string "background_info"
+ end
+
+ add_index "campaigns", ["assigned_to"], :name => "index_campaigns_on_assigned_to"
+ add_index "campaigns", ["user_id", "name", "deleted_at"], :name => "index_campaigns_on_user_id_and_name_and_deleted_at", :unique => true
+
+ create_table "comments", :force => true do |t|
+ t.integer "user_id"
+ t.integer "commentable_id"
+ t.string "commentable_type"
+ t.boolean "private"
+ t.string "title", :default => ""
+ t.text "comment"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ t.string "state", :limit => 16, :default => "Expanded", :null => false
+ end
+
+ create_table "contact_opportunities", :force => true do |t|
+ t.integer "contact_id"
+ t.integer "opportunity_id"
+ t.string "role", :limit => 32
+ t.datetime "deleted_at"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ end
+
+ create_table "contacts", :force => true do |t|
+ t.integer "user_id"
+ t.integer "lead_id"
+ t.integer "assigned_to"
+ t.integer "reports_to"
+ t.string "first_name", :limit => 64, :default => "", :null => false
+ t.string "last_name", :limit => 64, :default => "", :null => false
+ t.string "access", :limit => 8, :default => "Public"
+ t.string "title", :limit => 64
+ t.string "department", :limit => 64
+ t.string "source", :limit => 32
+ t.string "email", :limit => 64
+ t.string "alt_email", :limit => 64
+ t.string "phone", :limit => 32
+ t.string "mobile", :limit => 32
+ t.string "fax", :limit => 32
+ t.string "blog", :limit => 128
+ t.string "linkedin", :limit => 128
+ t.string "facebook", :limit => 128
+ t.string "twitter", :limit => 128
+ t.date "born_on"
+ t.boolean "do_not_call", :default => false, :null => false
+ t.datetime "deleted_at"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ t.string "background_info"
+ t.string "chinese_name"
+ t.string "preferred_name"
+ t.string "salutation"
+ t.string "skype", :limit => 128
+ end
+
+ add_index "contacts", ["assigned_to"], :name => "index_contacts_on_assigned_to"
+ add_index "contacts", ["user_id", "last_name", "deleted_at"], :name => "id_last_name_deleted", :unique => true
+
+ create_table "emails", :force => true do |t|
+ t.string "imap_message_id", :null => false
+ t.integer "user_id"
+ t.integer "mediator_id"
+ t.string "mediator_type"
+ t.string "sent_from", :null => false
+ t.string "sent_to", :null => false
+ t.string "cc"
+ t.string "bcc"
+ t.string "subject"
+ t.text "body"
+ t.text "header"
+ t.datetime "sent_at"
+ t.datetime "received_at"
+ t.datetime "deleted_at"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ t.string "state", :limit => 16, :default => "Expanded", :null => false
+ end
+
+ add_index "emails", ["mediator_id", "mediator_type"], :name => "index_emails_on_mediator_id_and_mediator_type"
+
+ create_table "field_groups", :force => true do |t|
+ t.string "name", :limit => 64
+ t.string "label", :limit => 128
+ t.integer "position"
+ t.string "hint"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ end
+
+ create_table "fields", :force => true do |t|
+ t.string "type"
+ t.integer "field_group_id"
+ t.string "klass_name", :limit => 32
+ t.integer "position"
+ t.string "name", :limit => 64
+ t.string "label", :limit => 128
+ t.string "hint"
+ t.string "placeholder"
+ t.string "as", :limit => 32
+ t.string "collection"
+ t.boolean "disabled"
+ t.boolean "required"
+ t.integer "maxlength"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ end
+
+ add_index "fields", ["field_group_id"], :name => "index_fields_on_field_group_id"
+ add_index "fields", ["klass_name"], :name => "index_fields_on_klass_name"
+ add_index "fields", ["name"], :name => "index_fields_on_name"
+
+ create_table "leads", :force => true do |t|
+ t.integer "user_id"
+ t.integer "campaign_id"
+ t.integer "assigned_to"
+ t.string "first_name", :limit => 64, :default => "", :null => false
+ t.string "last_name", :limit => 64, :default => "", :null => false
+ t.string "access", :limit => 8, :default => "Public"
+ t.string "title", :limit => 64
+ t.string "company", :limit => 64
+ t.string "source", :limit => 32
+ t.string "status", :limit => 32
+ t.string "referred_by", :limit => 64
+ t.string "email", :limit => 64
+ t.string "alt_email", :limit => 64
+ t.string "phone", :limit => 32
+ t.string "mobile", :limit => 32
+ t.string "blog", :limit => 128
+ t.string "linkedin", :limit => 128
+ t.string "facebook", :limit => 128
+ t.string "twitter", :limit => 128
+ t.integer "rating", :default => 0, :null => false
+ t.boolean "do_not_call", :default => false, :null => false
+ t.datetime "deleted_at"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ t.string "background_info"
+ t.string "skype", :limit => 128
+ end
+
+ add_index "leads", ["assigned_to"], :name => "index_leads_on_assigned_to"
+ add_index "leads", ["user_id", "last_name", "deleted_at"], :name => "index_leads_on_user_id_and_last_name_and_deleted_at", :unique => true
+
+ create_table "opportunities", :force => true do |t|
+ t.integer "user_id"
+ t.integer "campaign_id"
+ t.integer "assigned_to"
+ t.string "name", :limit => 64, :default => "", :null => false
+ t.string "access", :limit => 8, :default => "Public"
+ t.string "source", :limit => 32
+ t.string "stage", :limit => 32
+ t.integer "probability"
+ t.decimal "amount", :precision => 12, :scale => 2
+ t.decimal "discount", :precision => 12, :scale => 2
+ t.date "closes_on"
+ t.datetime "deleted_at"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ t.string "background_info"
+ end
+
+ add_index "opportunities", ["assigned_to"], :name => "index_opportunities_on_assigned_to"
+ add_index "opportunities", ["user_id", "name", "deleted_at"], :name => "id_name_deleted", :unique => true
+
+ create_table "permissions", :force => true do |t|
+ t.integer "user_id"
+ t.integer "asset_id"
+ t.string "asset_type"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ end
+
+ add_index "permissions", ["asset_id", "asset_type"], :name => "index_permissions_on_asset_id_and_asset_type"
+ add_index "permissions", ["user_id"], :name => "index_permissions_on_user_id"
+
+ create_table "preferences", :force => true do |t|
+ t.integer "user_id"
+ t.string "name", :limit => 32, :default => "", :null => false
+ t.text "value"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ end
+
+ add_index "preferences", ["user_id", "name"], :name => "index_preferences_on_user_id_and_name"
+
+ create_table "sessions", :force => true do |t|
+ t.string "session_id", :null => false
+ t.text "data"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ end
+
+ add_index "sessions", ["session_id"], :name => "index_sessions_on_session_id"
+ add_index "sessions", ["updated_at"], :name => "index_sessions_on_updated_at"
+
+ create_table "settings", :force => true do |t|
+ t.string "name", :limit => 32, :default => "", :null => false
+ t.text "value"
+ t.text "default_value"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ end
+
+ add_index "settings", ["name"], :name => "index_settings_on_name"
+
+ create_table "taggings", :force => true do |t|
+ t.integer "tag_id"
+ t.integer "taggable_id"
+ t.integer "tagger_id"
+ t.string "tagger_type"
+ t.string "taggable_type"
+ t.string "context"
+ t.datetime "created_at"
+ end
+
+ add_index "taggings", ["tag_id"], :name => "index_taggings_on_tag_id"
+ add_index "taggings", ["taggable_id", "taggable_type", "context"], :name => "index_taggings_on_taggable_id_and_taggable_type_and_context"
+
+ create_table "tags", :force => true do |t|
+ t.string "name"
+ end
+
+ create_table "tasks", :force => true do |t|
+ t.integer "user_id"
+ t.integer "assigned_to"
+ t.integer "completed_by"
+ t.string "name", :default => "", :null => false
+ t.integer "asset_id"
+ t.string "asset_type"
+ t.string "priority", :limit => 32
+ t.string "category", :limit => 32
+ t.string "bucket", :limit => 32
+ t.datetime "due_at"
+ t.datetime "completed_at"
+ t.datetime "deleted_at"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ t.string "background_info"
+ end
+
+ add_index "tasks", ["assigned_to"], :name => "index_tasks_on_assigned_to"
+ add_index "tasks", ["user_id", "name", "deleted_at"], :name => "index_tasks_on_user_id_and_name_and_deleted_at", :unique => true
+
+ create_table "users", :force => true do |t|
+ t.string "username", :limit => 32, :default => "", :null => false
+ t.string "email", :limit => 64, :default => "", :null => false
+ t.string "first_name", :limit => 32
+ t.string "last_name", :limit => 32
+ t.string "title", :limit => 64
+ t.string "company", :limit => 64
+ t.string "alt_email", :limit => 64
+ t.string "phone", :limit => 32
+ t.string "mobile", :limit => 32
+ t.string "aim", :limit => 32
+ t.string "yahoo", :limit => 32
+ t.string "google", :limit => 32
+ t.string "skype", :limit => 32
+ t.string "password_hash", :default => "", :null => false
+ t.string "password_salt", :default => "", :null => false
+ t.string "persistence_token", :default => "", :null => false
+ t.string "perishable_token", :default => "", :null => false
+ t.datetime "last_request_at"
+ t.datetime "last_login_at"
+ t.datetime "current_login_at"
+ t.string "last_login_ip"
+ t.string "current_login_ip"
+ t.integer "login_count", :default => 0, :null => false
+ t.datetime "deleted_at"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ t.boolean "admin", :default => false, :null => false
+ t.datetime "suspended_at"
+ t.string "single_access_token"
+ end
+
+ add_index "users", ["email"], :name => "index_users_on_email"
+ add_index "users", ["last_request_at"], :name => "index_users_on_last_request_at"
+ add_index "users", ["perishable_token"], :name => "index_users_on_perishable_token"
+ add_index "users", ["persistence_token"], :name => "index_users_on_remember_token"
+ add_index "users", ["username", "deleted_at"], :name => "index_users_on_username_and_deleted_at", :unique => true
+
+end
diff --git a/lib/fat_free_crm/fields.rb b/lib/fat_free_crm/fields.rb
index 23d64d8712..79b307a2ff 100755
--- a/lib/fat_free_crm/fields.rb
+++ b/lib/fat_free_crm/fields.rb
@@ -25,14 +25,12 @@ def self.included(base)
module ClassMethods
def has_fields
unless included_modules.include?(InstanceMethods)
- include InstanceMethods
extend SingletonMethods
+ include InstanceMethods
end
end
end
- module InstanceMethods
- end
module SingletonMethods
def fields
@@ -47,5 +45,28 @@ def custom_fields
fields.where(:type => "CustomField")
end
end
+
+
+ module InstanceMethods
+ def attributes=(new_attributes, guard_protected_attributes = true)
+ super
+ # If attribute is unknown, a new custom field may have been added.
+ # Refresh columns and try again.
+ rescue ActiveRecord::UnknownAttributeError
+ self.class.reset_column_information
+ super
+ end
+
+ def method_missing(method, *args)
+ if method.to_s =~ /^cf_/
+ # Refresh columns and try again.
+ self.class.reset_column_information
+ respond_to?(method) ? send(method, *args) : nil
+ else
+ super
+ end
+ end
+ end
end
end
+
diff --git a/public/images/facebook-close.gif b/public/images/facebook-close.gif
new file mode 100644
index 0000000000..cc2199248c
Binary files /dev/null and b/public/images/facebook-close.gif differ
diff --git a/public/javascripts/application.js b/public/javascripts/application.js
index f7aceb5150..237e376bec 100644
--- a/public/javascripts/application.js
+++ b/public/javascripts/application.js
@@ -15,6 +15,8 @@
// along with this program. If not, see .
//------------------------------------------------------------------------------
+var fbtaglist = null;
+
var crm = {
EXPANDED : "▼",
@@ -56,10 +58,23 @@ var crm = {
},
//----------------------------------------------------------------------------
+ search_tagged: function(query, controller) {
+ if ($('query')) {
+ $('query').value = query;
+ }
+ crm.search(query, controller);
+ },
+
+ /*
+ * remove any duplicate 'facebook-list' elements before running the 'BlindUp' effect.
+ * (The disappearing facebook-list takes precedence over the newly created facebook-list
+ * that is being AJAX loaded, and messes up the initialization.. )
+ */
hide_form: function(id) {
+ if($('facebook-list')) $('facebook-list').remove();
var arrow = $(id + "_arrow") || $("arrow");
arrow.update(this.COLLAPSED);
- Effect.BlindUp(id, { duration: 0.25, afterFinish: function() { $(id).update("") } });
+ Effect.BlindUp(id, { duration: 0.25, afterFinish: function() { $(id).update("").setStyle({height: 'auto'}); } });
},
//----------------------------------------------------------------------------
diff --git a/public/javascripts/facebooklist.js b/public/javascripts/facebooklist.js
new file mode 100644
index 0000000000..7f3f79c0ed
--- /dev/null
+++ b/public/javascripts/facebooklist.js
@@ -0,0 +1,548 @@
+
+/*
+ Proto!MultiSelect
+ Copyright: InteRiders - Distributed under MIT - Keep this message!
+*/
+
+// Added key contstant for COMMA watching happiness
+Object.extend(Event, {
+ KEY_COMMA: {code: 188, value:","},
+ KEY_SPACE: {code: 32, value:" "}
+});
+
+var ResizableTextbox = Class.create({
+ initialize: function(element, options) {
+ var that = this;
+ this.options = $H({
+ min: 5,
+ max: 500,
+ step: 7
+ });
+ this.options.update(options);
+ this.el = $(element);
+ this.width = this.el.offsetWidth;
+ this.el.observe(
+ 'keyup', function() {
+ var newsize = that.options.get('step') * $F(this).length;
+ if(newsize <= that.options.get('min')) newsize = that.width;
+ if(! ($F(this).length == this.retrieveData('rt-value') || newsize <= that.options.min || newsize >= that.options.max))
+ this.setStyle({'width': newsize});
+ }).observe('keydown', function() {
+ this.cacheData('rt-value', $F(this).length);
+ }
+ );
+ }
+});
+
+var TextboxList = Class.create({
+ initialize: function(element, options) {
+ this.options = $H({/*
+ onFocus: $empty,
+ onBlur: $empty,
+ onInputFocus: $empty,
+ onInputBlur: $empty,
+ onBoxFocus: $empty,
+ onBoxBlur: $empty,
+ onBoxDispose: $empty,*/
+ resizable: {},
+ className: 'bit',
+ separator: Event.KEY_COMMA,
+ tabindex: null,
+ extrainputs: true,
+ startinput: true,
+ hideempty: true,
+ newValues: false,
+ newValueDelimiters: ['[',']'],
+ spaceReplace: '',
+ fetchFile: undefined,
+ fetchMethod: 'get',
+ results: 10,
+ maxResults: 0, // 0 = set to default (which is 10 (see FacebookList class)),
+ wordMatch: false,
+ onEmptyInput: function(input){},
+ onAdd: function(tag){},
+ onDispose: function(tag){},
+ caseSensitive: false,
+ regexSearch: true
+ });
+ this.current_input = "";
+ this.options.update(options);
+ this.element = $(element).hide();
+ this.bits = new Hash();
+ this.events = new Hash();
+ this.count = 0;
+ this.current = false;
+ this.maininput = this.createInput({className: 'maininput'});
+ this.maininput.addClassName('maininput');
+ this.holder = new Element('ul', {
+ className: 'holder'
+ }).insert(this.maininput);
+ if(this.options.get('tabindex')) {
+ this.maininput.down('input').writeAttribute('tabindex', this.options.get('tabindex'));
+ }
+ this.element.insert({'before':this.holder});
+ this.holder.observe('click', function(event){
+ event.stop();
+ if(this.maininput != this.current) this.focus(this.maininput);
+ }.bind(this));
+ this.makeResizable(this.maininput);
+ this.setEvents();
+ },
+
+ setEvents: function() {
+ document.observe(Prototype.Browser.IE ? 'keydown' : 'keypress', function(e) {
+ if(! this.current) return;
+ if(this.current.retrieveData('type') == 'box' && e.keyCode == Event.KEY_BACKSPACE) e.stop();
+ }.bind(this));
+
+ document.observe(
+ 'keyup', function(e) {
+ e.stop();
+ if(! this.current) return;
+ switch(e.keyCode){
+ case Event.KEY_LEFT: return this.move('left');
+ case Event.KEY_RIGHT: return this.move('right');
+ case Event.KEY_DELETE:
+ case Event.KEY_BACKSPACE: return this.moveDispose();
+ }
+ }.bind(this)).observe(
+ 'click', function() { document.fire('blur'); }.bindAsEventListener(this)
+ );
+ },
+
+ update: function() {
+ this.element.value = this.bits.values().join(this.options.get('separator').value);
+ if (!this.current_input.blank()){
+ this.element.value += (this.element.value.blank() ? "" : this.options.get('separator').value) + this.current_input;
+ }
+ return this;
+ },
+
+ add: function(text, html) {
+ var id = this.id_base + '-' + this.count++;
+ var el = this.createBox($pick(html, text), {'id': id, 'class': this.options.get('className'), 'newValue' : text.newValue ? 'true' : 'false'});
+ (this.current || this.maininput).insert({'before':el});
+ el.observe('click', function(e) {
+ e.stop();
+ this.focus(el);
+ }.bind(this));
+ this.bits.set(id, text.value);
+ // Dynamic updating... why not?
+ this.update();
+ if(this.options.get('extrainputs') && (this.options.get('startinput') || el.previous())) this.addSmallInput(el,'before');
+ this.options.get('onAdd')(text.value, el);
+ return el;
+ },
+
+ addSmallInput: function(el, where) {
+ var input = this.createInput({'class': 'smallinput'});
+ el.insert({}[where] = input);
+ input.cacheData('small', true);
+ this.makeResizable(input);
+ if(this.options.get('hideempty')) input.hide();
+ return input;
+ },
+
+ dispose: function(el) {
+ this.options.get('onDispose')(this.bits.get(el.id));
+ this.bits.unset(el.id);
+ // Dynamic updating... why not?
+ this.update();
+ if(el.previous() && el.previous().retrieveData('small')) el.previous().remove();
+ if(this.current == el) this.focus(el.next());
+ if(el.retrieveData('type') == 'box') el.onBoxDispose(this);
+ el.remove();
+ return this;
+ },
+
+ focus: function(el, nofocus) {
+ if(! this.current) el.fire('focus');
+ else if(this.current == el) return this;
+ this.blur();
+ el.addClassName(this.options.get('className') + '-' + el.retrieveData('type') + '-focus');
+ if(el.retrieveData('small')) el.setStyle({'display': 'block'});
+ if(el.retrieveData('type') == 'input') {
+ el.onInputFocus(this);
+ if(! nofocus) this.callEvent(el.retrieveData('input'), 'focus');
+ }
+ else el.fire('onBoxFocus');
+ this.current = el;
+ return this;
+ },
+
+ blur: function(noblur) {
+ if(! this.current) return this;
+ if(this.current.retrieveData('type') == 'input') {
+ var input = this.current.retrieveData('input');
+ if(! noblur) this.callEvent(input, 'blur');
+ input.onInputBlur(this);
+ }
+ else this.current.fire('onBoxBlur');
+ if(this.current.retrieveData('small') && ! input.get('value') && this.options.get('hideempty'))
+ this.current.hide();
+ this.current.removeClassName(this.options.get('className') + '-' + this.current.retrieveData('type') + '-focus');
+ this.current = false;
+ return this;
+ },
+
+ createBox: function(text, options) {
+ var box = new Element('li', options).addClassName(this.options.get('className') + '-box').update(text.caption).cacheData('type', 'box');
+ return box;
+ },
+
+ createInput: function(options) {
+ var opts = Object.extend(options,{'type': 'text', 'autocomplete':'off'});
+ var li = new Element('li', {className: this.options.get('className') + '-input'});
+ var el = new Element('input', options);
+ el.observe('click', function(e) { e.stop(); }).observe('focus', function(e) { if(! this.isSelfEvent('focus')) this.focus(li, true); }.bind(this)).observe('blur', function() { if(! this.isSelfEvent('blur')) this.blur(true); }.bind(this)).observe('keydown', function(e) { this.cacheData('lastvalue', this.value).cacheData('lastcaret', this.getCaretPosition()); });
+ var tmp = li.cacheData('type', 'input').cacheData('input', el).insert(el);
+ return tmp;
+ },
+
+ callEvent: function(el, type) {
+ this.events.set(type, el);
+ el[type]();
+ },
+
+ isSelfEvent: function(type) {
+ return (this.events.get(type)) ? !! this.events.unset(type) : false;
+ },
+
+ makeResizable: function(li) {
+ var el = li.retrieveData('input');
+ el.cacheData('resizable', new ResizableTextbox(el, Object.extend(this.options.get('resizable'),{min: el.offsetWidth, max: (this.element.getWidth()?this.element.getWidth():50)})));
+ return this;
+ },
+
+ checkInput: function() {
+ var input = this.current.retrieveData('input');
+ return (! input.retrieveData('lastvalue') || (input.getCaretPosition() === 0 && input.retrieveData('lastcaret') === 0));
+ },
+
+ move: function(direction) {
+ var el = this.current[(direction == 'left' ? 'previous' : 'next')]();
+ if(el && (! this.current.retrieveData('input') || ((this.checkInput() || direction == 'right')))) this.focus(el);
+ return this;
+ },
+
+ moveDispose: function() {
+ if(this.current.retrieveData('type') == 'box') return this.dispose(this.current);
+ if(this.checkInput() && this.bits.keys().length && this.current.previous()) return this.focus(this.current.previous());
+ }
+});
+
+//helper functions
+Element.addMethods({
+ getCaretPosition: function() {
+ if (this.createTextRange) {
+ var r = document.selection.createRange().duplicate();
+ r.moveEnd('character', this.value.length);
+ if (r.text === '') return this.value.length;
+ return this.value.lastIndexOf(r.text);
+ } else return this.selectionStart;
+ },
+ cacheData: function(element, key, value) {
+ if (Object.isUndefined(this[$(element).identify()]) || !Object.isHash(this[$(element).identify()]))
+ this[$(element).identify()] = $H();
+ this[$(element).identify()].set(key,value);
+ return element;
+ },
+ retrieveData: function(element,key) {
+ return this[$(element).identify()].get(key);
+ }
+});
+
+function $pick(){for(var B=0,A=arguments.length;B= 0) {
+ matches[matches_found++] = this.data[i];
+ }
+ }
+ }
+ } else {
+ if (this.options.get('wordMatch')) {
+ var regexp = new RegExp("(^|\\s)"+search,(!this.options.get('caseSensitive') ? 'i' : ''));
+ } else {
+ var regexp = new RegExp(search,(!this.options.get('caseSensitive') ? 'i' : ''));
+ var matches = this.data.filter(
+ function(str) {
+ return str ? regexp.test(str.evalJSON(true).caption) : false;
+ });
+ }
+ }
+
+ var count = 0;
+ matches = matches.compact();
+ matches = matches.sortBy(function(m) {
+ m = m.evalJSON(true);
+ return m.value.startsWith(search);
+ }).reverse();
+ matches.each(
+ function(result, ti) {
+ count++;
+ if(ti >= (this.options.get('maxResults') ? this.options.get('maxResults') : this.loptions.get('autocomplete').maxresults)) return;
+ var that = this;
+ var el = new Element('li');
+ el.observe('click',function(e) {
+ e.stop();
+ that.current_input = "";
+ that.autoAdd(this);
+ }
+ ).observe('mouseover', function() { that.autoFocus(this); } ).update(
+ this.autoHighlight(result.evalJSON(true).caption, search)
+ );
+ this.autoresults.insert(el);
+ el.cacheData('result', result.evalJSON(true));
+ if(ti == 0) this.autoFocus(el);
+ },
+ this
+ );
+ }
+ if (count == 0) {
+ // if there are no results, hide everything so that KEY_ENTER has no effect
+ this.autoHide();
+ } else {
+ if (count > this.options.get('results'))
+ this.autoresults.setStyle({'height': (this.options.get('results')*24)+'px'});
+ else
+ this.autoresults.setStyle({'height': (count?(count*24):0)+'px'});
+ }
+
+ return this;
+ },
+
+ autoHighlight: function(html, highlight) {
+ return html.gsub(new RegExp(highlight,'i'), function(match) {
+ return '' + match[0] + '';
+ });
+ },
+
+ autoHide: function() {
+ this.resultsshown = false;
+ this.autoholder.hide();
+ return this;
+ },
+
+ autoFocus: function(el) {
+ if(! el) return;
+ if(this.autocurrent) this.autocurrent.removeClassName('auto-focus');
+ this.autocurrent = el.addClassName('auto-focus');
+ return this;
+ },
+
+ autoMove: function(direction) {
+ if(!this.resultsshown) return;
+ this.autoFocus(this.autocurrent[(direction == 'up' ? 'previous' : 'next')]());
+ this.autoresults.scrollTop = this.autocurrent.positionedOffset()[1]-this.autocurrent.getHeight();
+ return this;
+ },
+
+ autoFeed: function(text) {
+ var with_case = this.options.get('caseSensitive');
+ if (this.data.indexOf(Object.toJSON(text)) == -1) {
+ this.data.push(Object.toJSON(text));
+ this.data_searchable.push(with_case ? Object.toJSON(text).evalJSON(true).caption : Object.toJSON(text).evalJSON(true).caption.toLowerCase());
+ }
+ return this;
+ },
+
+ autoAdd: function(el) {
+ if(this.newvalue && this.options.get("newValues")) {
+ this.add({caption: el.value, value: el.value, newValue: true});
+ var input = el;
+ } else if(!el || ! el.retrieveData('result')) {
+ return;
+ } else {
+ this.add(el.retrieveData('result'));
+ delete this.data[this.data.indexOf(Object.toJSON(el.retrieveData('result')))];
+ var input = this.lastinput || this.current.retrieveData('input');
+ }
+ this.autoHide();
+ input.clear().focus();
+ return this;
+ },
+
+ createInput: function($super,options) {
+ var li = $super(options);
+ var input = li.retrieveData('input');
+
+ if(options['className'] == "maininput") {
+ // Give the input a hook for our cucumber tests to use.
+ input.setAttribute('name', 'fblist-maininput');
+ };
+
+ input.observe('keydown', function(e) {
+ this.dosearch = false;
+ this.newvalue = false;
+
+ switch(e.keyCode) {
+ case Event.KEY_UP: e.stop(); return this.autoMove('up');
+ case Event.KEY_DOWN: e.stop(); return this.autoMove('down');
+
+ case Event.KEY_RETURN:
+ // If the text input is blank and the user hits Enter call the
+ // onEmptyInput callback.
+ if (String('').valueOf() == String(this.current.retrieveData('input').getValue()).valueOf()) {
+ this.options.get("onEmptyInput")();
+ }
+ e.stop();
+ if(!this.autocurrent || !this.resultsshown) break;
+ this.current_input = "";
+ this.autoAdd(this.autocurrent);
+ this.autocurrent = false;
+ this.autoenter = true;
+ break;
+ case Event.KEY_ESC:
+ this.autoHide();
+ if(this.current && this.current.retrieveData('input'))
+ this.current.retrieveData('input').clear();
+ break;
+ default:
+ this.dosearch = true;
+ }
+ }.bind(this));
+ input.observe('keyup',function(e) {
+ var code = this.options.get('separator').code;
+ var splitOn = this.options.get('separator').value;
+ switch(e.keyCode) {
+ case code:
+ if(this.options.get('newValues')) {
+ new_value_el = this.current.retrieveData('input');
+ if (!new_value_el.value.endsWith('<')) {
+ keep_input = "";
+ if (new_value_el.value.indexOf(splitOn) < (new_value_el.value.length - splitOn.length)){
+ separator_pos = new_value_el.value.indexOf(splitOn);
+ keep_input = new_value_el.value.substr(separator_pos + 1);
+ new_value_el.value = new_value_el.value.substr(0,separator_pos).escapeHTML().strip();
+ } else {
+ new_value_el.value = new_value_el.value.gsub(splitOn,"").escapeHTML().strip();
+ }
+ if(!this.options.get("spaceReplace").blank()) new_value_el.value.gsub(" ", this.options.get("spaceReplace"));
+ if(!new_value_el.value.blank()) {
+ e.stop();
+ this.newvalue = true;
+ this.current_input = keep_input.escapeHTML().strip();
+ this.autoAdd(new_value_el);
+ input.value = keep_input;
+ this.update();
+ }
+ }
+ }
+ break;
+ case Event.KEY_UP:
+ case Event.KEY_DOWN:
+ case Event.KEY_RETURN:
+ case Event.KEY_ESC:
+ break;
+ default:
+ // If the user doesn't add comma after, the value is discarded upon submit
+ this.current_input = input.value.strip().escapeHTML();
+ this.update();
+
+ // Removed Ajax.Request from here and moved to initialize,
+ // now doesn't create server queries every search but only
+ // refreshes the list on initialize (page load)
+ if(this.searchTimeout) clearTimeout(this.searchTimeout);
+ this.searchTimeout = setTimeout(function(){
+ var sanitizer = new RegExp("[({[^$*+?\\\]})]","g");
+ if(this.dosearch) this.autoShow(input.value.replace(sanitizer,"\\$1"));
+ }.bind(this), 250);
+ }
+ }.bind(this));
+ input.observe(Prototype.Browser.IE ? 'keydown' : 'keypress', function(e) {
+ if ((e.keyCode == Event.KEY_RETURN) && this.autoenter) e.stop();
+ this.autoenter = false;
+ }.bind(this));
+ return li;
+ },
+
+ createBox: function($super,text, options) {
+ var li = $super(text, options);
+ li.observe('mouseover',function() {
+ this.addClassName('bit-hover');
+ }).observe('mouseout',function() {
+ this.removeClassName('bit-hover');
+ });
+ var a = new Element('a', {
+ 'href': '#',
+ 'class': 'closebutton'
+ });
+ a.observe('click',function(e) {
+ e.stop();
+ if(! this.current) this.focus(this.maininput);
+ this.dispose(li);
+ }.bind(this));
+ li.insert(a).cacheData('text', Object.toJSON(text));
+ return li;
+ }
+});
+
+Element.addMethods({
+ onBoxDispose: function(item,obj) {
+ // Set to not to "add back" values in the drop-down upon delete if they were new values
+ item = item.retrieveData('text').evalJSON(true);
+ if(!item.newValue)
+ obj.autoFeed(item);
+ },
+ onInputFocus: function(el,obj) { obj.autoShow(); },
+ onInputBlur: function(el,obj) {
+ obj.lastinput = el;
+ if(!obj.curOn) {
+ obj.blurhide = obj.autoHide.bind(obj).delay(0.1);
+ }
+ },
+ filter: function(D,E) { var C=[];for(var B=0,A=this.length;B "cf_test_field", :klass_name => "Contact")
- c.klass.columns.map(&:name).should include("cf_test_field")
+ CustomField.connection.should_receive(:add_column).
+ with("contacts", "cf_test_field", :string, {})
+
+ c = Factory.create(:custom_field,
+ :as => "string",
+ :name => "cf_test_field",
+ :klass_name => "Contact")
end
it "should generate a unique column name for a custom field" do
@@ -55,8 +60,8 @@
it "should return a safe list of types for the 'as' select options" do
{"email" => %w(string email url tel select radio),
"integer" => %w(integer float)}.each do |type, expected_arr|
- c = Factory.create(:custom_field, :as => type)
- opts = c.edit_as_options
+ c = Factory.build(:custom_field, :as => type)
+ opts = c.available_as
expected_arr.each {|t| opts.should include(t) }
end
end
@@ -67,15 +72,28 @@ def ar_column(custom_field, column)
end
it "should change a column's type for safe transitions" do
+ CustomField.connection.should_receive(:add_column).
+ with("contacts", "cf_test_field", :string, {})
+ CustomField.connection.should_receive(:change_column).
+ with("contacts", "cf_test_field", :text, {})
+
c = Factory.create(:custom_field,
:label => "Test Field",
:name => nil,
:as => "email",
:klass_name => "Contact")
- ar_column(c, "cf_test_field").type.should == :string
c.as = "text"
c.save
- ar_column(c, "cf_test_field").type.should == :text
+ end
+
+ it "should refresh column info and retry on attribute error, in case a new custom field was added by a different instance" do
+ Contact.should_receive(:reset_column_information).twice
+
+ lambda { Contact.new :cf_unknown_field => 123 }.should raise_error(ActiveRecord::UnknownAttributeError)
+
+ contact = Factory.build(:contact)
+ contact.cf_another_new_field.should == nil
end
end
+
diff --git a/spec/support/auth_macros.rb b/spec/support/auth_macros.rb
index 18e5d6f273..9fb353bac8 100755
--- a/spec/support/auth_macros.rb
+++ b/spec/support/auth_macros.rb
@@ -18,7 +18,7 @@ def login(user_stubs = {}, session_stubs = {})
end
alias :require_user :login
-#- ---------------------------------------------------------------------------
+#----------------------------------------------------------------------------
def login_and_assign(user_stubs = {}, session_stubs = {})
login(user_stubs, session_stubs)
assigns[:current_user] = @current_user
@@ -41,3 +41,4 @@ def current_user
def current_user_session
@current_user_session
end
+