Skip to content

Commit

Permalink
[migration] Add customizable slugs.
Browse files Browse the repository at this point in the history
* 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
igal authored and reidab committed Nov 5, 2011
1 parent ce022ea commit 169f298
Show file tree
Hide file tree
Showing 24 changed files with 326 additions and 2 deletions.
9 changes: 9 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions app/controllers/companies_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions app/controllers/groups_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions app/controllers/people_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions app/controllers/projects_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
113 changes: 113 additions & 0 deletions app/mixins/customizable_slug.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions app/models/company.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions app/models/group.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions app/models/person.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -77,6 +79,7 @@ def self.find_or_create_sample(create_backreference=true)
end
return person
end

end


Expand Down
2 changes: 2 additions & 0 deletions app/models/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions app/views/companies/_form.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/views/groups/_form.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/views/people/_form.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/views/projects/_form.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -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'}
Expand Down
10 changes: 10 additions & 0 deletions app/views/site/_custom_slug_field.html.haml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions config/initializers/customizable_slug.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
class ActiveRecord::Base
include CustomizableSlug
end
7 changes: 7 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ en:
address: "Address"
category: "Category"
created_at: "Created at"
custom_slug: "URL slug"
description: "Description"
email: "Email address"
location: "Location"
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions config/locales/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions db/migrate/20110924171944_create_friendly_id_slugs.rb
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions db/migrate/20110924171945_add_slugs.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 169f298

Please sign in to comment.