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 +