Permalink
Browse files

[migration] Add customizable slugs.

* Implement CustomizableSlug mixin to wrap and improve FriendlyId.
* Add slug fields to models and views.
* Redirect show requests to old slugs based on history.
* Describe behavior in specs.
  • Loading branch information...
1 parent ce022ea commit 169f298ab1e45a997c00d6ba2f1ce8dd1daf3545 @igal igal committed with Sep 24, 2011
@@ -107,4 +107,13 @@ def page_title(value=nil)
end
end
helper_method :page_title
+
+ # Preserve old links to resources by redirecting to their current location.
+ #
+ # The "friendly_id" slug history tracks the resource's slug over time. If a resource is available under a newer slug name, redirect its "show" action to the current name. E.g. "/request/old-name" will redirect to "/request/new-name" if the slug was changed from "old-name" to "new-name".
+ def redirect_historical_slugs
+ if self.action_name == "show" && request.path != resource_path(resource)
+ return redirect_to resource, :status => :moved_permanently
+ end
+ end
end
@@ -4,6 +4,7 @@ class CompaniesController < InheritedResources::Base
:resource => [:join, :leave]
before_filter :authenticate_user!, :except => [:index, :show, :tag]
+ before_filter :redirect_historical_slugs
def tag
@tag = params[:tag]
@@ -4,6 +4,7 @@ class GroupsController < InheritedResources::Base
:resource => [:join, :leave]
before_filter :authenticate_user!, :except => [:index, :show, :tag]
+ before_filter :redirect_historical_slugs
def tag
@tag = params[:tag]
@@ -8,6 +8,7 @@ class PeopleController < InheritedResources::Base
before_filter :require_owner_or_admin!, :only => [:edit, :update, :destroy]
before_filter :pick_photo_input, :only => [:update, :create]
before_filter :set_user_id_if_admin, :only => [:update, :create]
+ before_filter :redirect_historical_slugs
def index
@view = :grid if params[:grid]
@@ -4,6 +4,7 @@ class ProjectsController < InheritedResources::Base
:resource => [:join, :leave]
before_filter :authenticate_user!, :except => [:index, :show, :tag]
+ before_filter :redirect_historical_slugs
def tag
@tag = params[:tag]
@@ -35,6 +35,8 @@ def welcome
@possible_duplicates += Person.unclaimed.where(name_part_query, *name_parts.map{|p| "%#{p}%"}).take(10)
@possible_duplicates.uniq!
+
+ @person.send(:set_slug)
end
end
end
@@ -0,0 +1,113 @@
+# = CustomizableSlug
+#
+# This mixin provides a easy-to-use wrapper for setting up customizable slugs
+# using the <tt>friendly_id</tt> plugin.
+#
+# @example To add a customizable slug whose default value is based on the :name
+# field, add this to your ActiveRecord model:
+#
+# class MyModel
+# customizable_slug_from :name
+# end
+#
+# @example To use the customizable field:
+#
+# m = MyModel.new(:name => "foo")
+#
+# m.save
+# m.slug # => "foo"
+#
+# m.custom_slug = "bar"
+# m.save
+# m.slug # => "bar"
+module CustomizableSlug
+
+ # Override behavior of gem.
+ require 'friendly_id/slug_generator'
+ class CustomizableSlugGenerator < FriendlyId::SlugGenerator
+ # When generating the slug, if there's a conflict, stop immediately and
+ # mark the record as an error so user can pick something else.
+ #
+ # The gem's original behavior is to always generate a unique slug, even if
+ # this means adding a number to the end of it. This is bad design because
+ # if the user wants to be "foo", but that's taken, they'll end up as
+ # "foo-2", rather than being told to pick another slug.
+ def generate
+ if conflict?
+ sluggable.errors.add(:custom_slug, I18n.t('activerecord.errors.messages.taken'))
+ end
+ return normalized
+ end
+
+ # Check history for conflicts, if using history.
+ #
+ # The gem's original behavior doesn't check history, so if you try to
+ # create a conflicting record, the #save will fail with a raw SQL
+ # uniqueness constraint error.
+ def conflicts
+ # If any regular conflicts are found, return them immediately.
+ scope = super
+ return scope if scope.count > 0
+
+ # If no regular conflicts are found, search the history.
+ if friendly_id_config.model_class.included_modules.include?(FriendlyId::History)
+ history = FriendlyId::Slug.where(:slug => normalized, :sluggable_type => self.sluggable.class.to_s)
+ unless self.sluggable.new_record?
+ # If record exists, exclude it from the history check.
+ history = history.where('sluggable_id <> ?', self.sluggable.id)
+ end
+
+ return history if history.count > 0
+ end
+
+ # No conflicts of any sort found.
+ return []
+ end
+ end
+
+ def self.included(base)
+ base.send(:extend, ::CustomizableSlug::ClassMethods)
+ end
+
+ module ClassMethods
+ # Managing customizable custom slug for +attribute+, e.g. :name.
+ def customizable_slug_from(attribute)
+ # Attribute which contains the source value to use for generating the
+ # slug, e.g. :name.
+ cattr_accessor :friendly_id_source_attribute
+ self.friendly_id_source_attribute = attribute
+
+ # Activate "friendly_id" plugin.
+ extend FriendlyId
+ friendly_id :custom_slug_or_source, :use => :history, :slug_generator_class => ::CustomizableSlug::CustomizableSlugGenerator
+
+ # Add validation for "custom_slug" field.
+ validate :validate_custom_slug
+ end
+ end
+
+ # Return the user-specified custom slug or the friendly id for this record.
+ def custom_slug
+ @custom_slug.presence || self.friendly_id
+ end
+
+ # Set the custom slug to +value+.
+ def custom_slug=(value)
+ @custom_slug = value
+ end
+
+ # Return the custom slug or the value of the attribute that contains the source value.
+ def custom_slug_or_source
+ @custom_slug.presence || "#{self.send(self.class.friendly_id_source_attribute)}"
+ end
+
+ def validate_custom_slug
+ # Ensure the slug starts with a letter, or Rails will run #to_i on it to
+ # get a number and find the record by numeric id rather than the slug. :(
+ #
+ # TODO Figure out what to do about slugs that really start with a digit, e.g. "37signals".
+ if @custom_slug.present? && @custom_slug !~ /^\D/
+ self.errors.add(:custom_slug, I18n.t('activerecord.errors.must_start_with_non_digit'))
+ end
+ end
+end
View
@@ -14,6 +14,8 @@ class Company < ActiveRecord::Base
import_image_from_url_as :logo
+ customizable_slug_from :name
+
has_many :company_projects
has_many :projects, :through => :company_projects
View
@@ -14,6 +14,8 @@ class Group < ActiveRecord::Base
import_image_from_url_as :logo
+ customizable_slug_from :name
+
has_many :group_projects
has_many :projects, :through => :group_projects
View
@@ -17,6 +17,8 @@ class Person < ActiveRecord::Base
import_image_from_url_as :photo, :gravatar => true
+ customizable_slug_from :name
+
belongs_to :user
accepts_nested_attributes_for :user, :update_only => true
@@ -77,6 +79,7 @@ def self.find_or_create_sample(create_backreference=true)
end
return person
end
+
end
View
@@ -14,6 +14,8 @@ class Project < ActiveRecord::Base
import_image_from_url_as :logo
+ customizable_slug_from :name
+
has_many :project_memberships
has_many :people, :through => :project_memberships
@@ -2,6 +2,7 @@
= display_errors_for @company
= f.inputs do
= f.input :name
+ = render 'site/custom_slug_field', :form => f
= f.input :url
= f.input :address, :as => :string
= f.input :description
@@ -2,6 +2,7 @@
= display_errors_for @group
= f.inputs do
= f.input :name
+ = render 'site/custom_slug_field', :form => f
= f.input :url
= f.input :mailing_list
= f.input :description
@@ -3,7 +3,7 @@
= display_errors_for @person
= f.inputs do
= f.input :name
- -# = f.input :twitter
+ = render 'site/custom_slug_field', :form => f
- unless @person.new_record?
= f.semantic_fields_for :user do |u|
= u.input :email
@@ -2,6 +2,7 @@
= display_errors_for @project
= f.inputs do
= f.input :name
+ = render 'site/custom_slug_field', :form => f
= f.input :url
= f.input :description
= f.input :tag_list, :as => :text, :input_html => {:class => 'tags'}
@@ -0,0 +1,10 @@
+-# Display the form field for a custom slug.
+-#
+-# ARGUMENTS:
+-# * form: The Rails form instance.
+- hint = "%{example} %{url}/<em><b>%{my}-%{model}</b></em>" % { |
+ :example => t('field.example.fragment'), |
+ :url => "#{SETTINGS.organization.url}/#{controller_path}", |
+ :my => t('field.my.fragment'), |
+ :model => t("activerecord.models.#{form.send(:model_name).underscore}").underscore }
+= form.input :custom_slug, :hint => hint.html_safe
@@ -0,0 +1,3 @@
+class ActiveRecord::Base
+ include CustomizableSlug
+end
View
@@ -7,6 +7,7 @@ en:
address: "Address"
category: "Category"
created_at: "Created at"
+ custom_slug: "URL slug"
description: "Description"
email: "Email address"
location: "Location"
@@ -30,6 +31,8 @@ en:
person:
bio: "Bio"
location: "Location"
+ errors:
+ must_start_with_non_digit: "must start with a non-digit"
sign_in: Sign In
group:
@@ -178,6 +181,8 @@ en:
label: "Date added" # people#index
email_address:
label: "Email address" # authentications#_login
+ example:
+ fragment: "e.g." # site#_custom_slug_field
group_list:
title: "Groups" # people#show
import_file:
@@ -191,6 +196,8 @@ en:
title: "Meeting Info" # groups#show
members:
title: "Members" # group#show
+ my:
+ fragment: "my" # site#_custom_slug_field
name:
label: "Name" # people#index
project_list:
View
@@ -5,6 +5,7 @@ fr:
address: "Adresse"
category: "Catégorie"
created_at: "Créé le"
+ custom_slug: "Sobriquet de l'URL" # TODO review translation
description: "Description"
email: "Adresse email ou identifiant"
location: "Adresse"
@@ -40,6 +41,8 @@ fr:
person:
bio: "Bio"
location: "Localisation"
+ errors:
+ must_start_with_non_digit: "doit commencer par un non-chiffres" # TODO review translation
sign_in: Connexion
group:
@@ -185,6 +188,8 @@ fr:
label: "Date de création" # people#index
email_address:
label: "Adresse email ou identifiant" # authentications#_login
+ example:
+ fragment: "pour exemple" # site#_custom_slug_field # TODO review translation
group_list:
title: "Groupes" # people#show
import_file:
@@ -198,6 +203,8 @@ fr:
title: "Infos réunion" # groups#show
members:
title: "Membres" # groups#show
+ my:
+ fragment: "mon" # site#_custom_slug_field # TODO review translation
name:
label: "Nom" # people#index
project_list:
@@ -0,0 +1,18 @@
+class CreateFriendlyIdSlugs < ActiveRecord::Migration
+
+ def self.up
+ create_table :friendly_id_slugs do |t|
+ t.string :slug, :null => false
+ t.integer :sluggable_id, :null => false
+ t.string :sluggable_type, :limit => 40
+ t.datetime :created_at
+ end
+ add_index :friendly_id_slugs, :sluggable_id
+ add_index :friendly_id_slugs, [:slug, :sluggable_type], :unique => true
+ add_index :friendly_id_slugs, :sluggable_type
+ end
+
+ def self.down
+ drop_table :friendly_id_slugs
+ end
+end
@@ -0,0 +1,20 @@
+class AddSlugs < ActiveRecord::Migration
+ TABLES = %w[people companies projects groups]
+
+ def self.up
+ for table in TABLES
+ add_column table, :slug, :string
+ add_index table, :slug, :unique => true
+
+ # Add slug to all records
+ table.classify.constantize.find_each(&:save)
+ end
+ end
+
+ def self.down
+ for table in TABLES
+ remove_index table, :slug
+ remove_column table, :slug
+ end
+ end
+end
Oops, something went wrong.

0 comments on commit 169f298

Please sign in to comment.