Skip to content

Instantly share code, notes, and snippets.

@burlesona
Created November 11, 2012 21:11
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save burlesona/4056285 to your computer and use it in GitHub Desktop.
Save burlesona/4056285 to your computer and use it in GitHub Desktop.
Example of user-friendly association of City and Districts to other models in Rails
# 1. Excerpt of Photo and User model
# Photos can belong to Cities and Districts, and users can browse Cities and
# Districts to see the photos in them. When a user uploads a photo I really want
# them to tell me where the photo came from.
# Also, Users can be from a City and District. This is not currently used for very
# much, but gives me a path to add location-enhanced results for other stuff in the
# future.
# Relevant excerpt of Photo model:
class Photo
belongs_to :city
belongs_to :district
...
end
# Relevant excerpt of User model:
class User
belongs_to :city
belongs_to :district
...
end
-# 2. Location Selector Template
-# When a user is viewing a page where they can edit a photo, the get this
-# location-selection widget. Relevant data is attached to the container, which
-# gets used by the client-side JS.
.location_select{ :data => { 'cities-path' => cities_path, 'object' => object.class, 'object-id' => object.id, 'city-id' => object.city.try(:id), 'district-id' => object.district.try(:id) } }
.input.city_select
= label_tag :city_name, 'City'
= text_field_tag :city_name, object.city.try(:full_name), :data => { :autocomplete_source => lookup_cities_path }, :id => nil, :class => 'city_name'
%span.status
.message
.input.district_select
= label_tag 'District / Neighborhood'
= text_field_tag :district_name, object.district.try(:name), :id => nil, :class => 'district_name'
%span.status
%span.hint Optional, leave blank if unknown.
.message
-# Note the partial is polymorphic so it takes the local variable `object`
-# instead of something more specific. It can be called in a view like so:
-# = render :partial => 'places/location_selector', :locals => { :object => @photo }
# 3. Location Selector CoffeeScript
# This is the heart of the widget, it sets up three handler objects. For each
# widget: a `LocationSelector` parent object, with a `CityHandler` and
# `DistrictHandler` attached.
# PLACES
# Initializers
ajaxReady ->
$('div.location_select').each ->
new LocationSelector $(this)
# Global Scope
root = exports ? this
root.S_KEYS = [8,27,32,46].concat([65..90]);
# HANDLER OBJECTS
# -- Location Selector --
# The location selector is simple object to bind the elements together
# and share some common functionality between them.
root.LocationSelector = ( container ) ->
@container= container
@city_handler = null
@district_handler = null
@located = false
this.initialize()
root.LocationSelector.prototype =
# On initialize it passes itself to a new CityHandler and
# DistrictHandler, then initializes the City Handler.
initialize: ->
@city_handler= new CityHandler(this)
@district_handler= new DistrictHandler(this)
@city_handler.initialize()
# Display is a shared method for updating the DOM with information
# for the user.
display: (element, status, message) ->
element.find('span.status').attr('class', 'status ' + status)
msgdiv = element.find('div.message')
if message
msgdiv.attr('class', 'message ' + status).html( message ).slideDown()
else
msgdiv.attr('class', 'message').html( message ).slideUp()
# This checks for a city-id set on the container.
city_id: ->
@container.data 'city-id'
# This checks for a district-id set on the container.
district_id: ->
@container.data 'district-id'
# This sets a city id in the object and on the container, and
# triggers the district handler. It also sends the `located`
# event, which other parts of the UI may be listening for.
setLocation: ->
@container.data 'city-id', @city_handler.id
@located = true
@district_handler.initialize()
@container.triggerHandler 'located'
# This unsets a city id in the object and the container, and
# triggers the district handler. It also sends the 'notlocated'
# event, which other parts of the UI may be listening for.
unsetLocation: ->
@container.data 'city-id', null
@located = false
@district_handler.initialize()
@container.triggerHandler 'notLocated'
# -- City Handler --
# The City Handler takes care of the `city_name` input.
root.CityHandler = (locationSelector) ->
@LS = locationSelector
@container= locationSelector.container
@name_selector= @container.find('input.city_name')
@id = null
@timer= null
return this
root.CityHandler.prototype =
# On initialize this attaches a listener to the city_name input
# and sets a status of 'success' if the LocationSelector already
# knows its CityID (ie the object already is associated with a city).
initialize: ->
this.bindName()
if @LS.city_id()
this.setStatus 'success'
this.setID @LS.city_id()
# This sets the status of the city selector.
setStatus: (status, message) ->
@LS.display @container.find('div.city_select'), status, message
# This listens to the city_name input and triggers the .get() method
# when a user stops typing. It also binds the jQuery UI autocomplete
# method to provide suggested cities to the user.
bindName: ->
self = this
@name_selector.bindAutocomplete
select: ->
self.get()
window.clearTimeout( self.timer )
@name_selector.on 'keyup', (event) ->
if event.keyCode in S_KEYS
self.setStatus 'loading'
self.clearID()
if self.timer
window.clearTimeout( self.timer )
self.timer = window.setTimeout(
-> self.get()
2000
)
# This method queries the server with the text the user has input so far.
# It's not possible to know whether an obscure placename is totally valid
# so any input may be accepted, but the association will not be created
# unless an existing match is found in the DB, or the user confirms the request.
get: (create) ->
self = this
unless @name_selector.val()
self.setStatus ''
return false
params = {location_string: @name_selector.val(), object: @container.data('object'), object_id: @container.data('object-id')}
if create == true
params["create"] = true
$.ajax
type: 'POST'
url: self.container.data('cities-path')
dataType: 'json'
data: params
error: (xhr, status, error) ->
self.setStatus 'error', 'Unable to find city, please try adding a state or country name.'
# If the request is successful either the object is associated with an
# existing city record, or the user is asked to confirm the creation of a new
# city.
success: (data, status, xhr) ->
if data.city.new_record
self.confirmCreate data.city
else
self.setStatus 'success'
self.name_selector.val( data.city.full_name )
self.setID data.city.id
# This method prompts the user to confirm their selection if it is new to the app, and
# resubmits the request confirming creation of a new record.
confirmCreate: (city) ->
self = this
message = 'No record for ' + city.full_name + '. <a href="#" class="confirm_city button small">Create it?</a>'
this.setStatus 'notice', message
@container.find('a.confirm_city').click (event) ->
event.preventDefault()
self.setStatus 'loading'
self.get(true)
# This method unsets the association.
clearID: ->
@id = null
@LS.unsetLocation()
# This method sets the association.
setID: (id) ->
@id = id
@LS.setLocation()
# -- District Handler --
# This is turned on or "rebooted" by changes to the location selector
# triggered by the City Handler. It works in virtually the same way.
root.DistrictHandler = (locationSelector) ->
@LS = locationSelector
@container= locationSelector.container
@name_selector= @container.find('input.district_name')
@id= null
@timer= null
this.disable()
return this
root.DistrictHandler.prototype =
# Districts are nested under cities. This method builds a URL to
# post to.
path: ->
@container.data('cities-path') + '/' + @LS.city_handler.id + '/districts'
# This method ensures the City association is present and then binds
# the district name input to a listener.
initialize: ->
if @LS.located
this.enable()
if @LS.district_id()
this.setID @LS.district_id()
this.setStatus 'success'
# This sets the status of the district_name input.
setStatus: (status, message) ->
@LS.display @container.find('div.district_select'), status, message
# This disables the district_name input.
disable: ->
@name_selector.attr 'disabled', true
@name_selector.off 'autocomplete'
@name_selector.off 'keyup'
# This enables the district name input.
enable: ->
@name_selector.removeAttr('disabled')
this.bindName()
# This method sets up autocomplete and the listener on the
# district name input, and triggers the .get() method when
# the user stops typing.
bindName: ->
self = this
@name_selector.autocomplete {source: self.path()},
select: ->
self.get()
window.clearTimeout( self.timer )
@name_selector.on 'keyup', (event) ->
if event.keyCode in S_KEYS
self.setStatus 'loading'
self.clearID()
if self.timer
window.clearTimeout( self.timer )
self.timer = window.setTimeout(
-> self.get()
2000
)
# This method queries the server to see if a matching district
# for the given city exists. If so it creates the association,
# if not it prompts the user to confirm.
get: (create) ->
self = this
unless @name_selector.val()
self.setStatus ''
return false
params = { name: @name_selector.val(), object: @container.data('object'), object_id: @container.data('object-id') }
if create == true
params["create"] = true
$.ajax
type: 'POST'
url: self.path()
dataType: 'json'
data: params
error: (xhr, status, error) ->
self.clearID()
self.setStatus 'error', 'Server error, please try again.'
success: (data, status, xhr) ->
if data.district.new_record
self.confirmCreate data.district
else
self.setStatus 'success'
self.name_selector.val( data.district.name )
self.setID data.district.id
# This method prompts the user to confirm creation of a new
# district record, and resubmits the query with confirmation.
confirmCreate: (district) ->
self = this
message = 'No record for ' +district.name+ ' (' +district.city_name+ ') <a href="#" class="confirm_district button small">Create it?</a>'
this.setStatus 'notice', message
@container.find('a.confirm_district').click (event) ->
event.preventDefault()
self.setStatus 'loading'
self.get(true)
# This method clears the association (for instance if the user
# changed the name to something invalid).
clearID: ->
@id = null
# This method records the association.
setID: (id) ->
@id = id
# 4. CitiesController
# The CitiesController responds to requests from the client JS above in the
# following actions:
class CitiesController < ApplicationController
respond_to :html, :json
...
# This method receives js post requests to /cities and responds to them.
def create
@city = City.find_or_init_or_create_by_string(params[:location_string],params[:create])
associate_object if @city.valid? && params[:object]
respond_with @city
end
# This method is for jQuery UI autocomplete.
def lookup
@cities = City.order(:name).where( "name like ?", "%#{params[:term]}%" )
render :json => @cities.map(&:full_name)
end
private
# This method is for handling polymorphism, as the object could be a user
# or a city. This is my least-favorite part of the process, I think it
# could be more elegant, but it works as expected.
def associate_object
if params[:object] == 'Photo' && params[:object_id]
@city.photos << Photo.find(params[:object_id])
elsif params[:object] == 'User' && params[:object_id]
@city.users << User.find(params[:object_id])
end
end
...
end
# 5. City JSON Template (rabl)
# This is a JSON template for the City object that the Cities Controller
# responds with.
object @city
attributes :id, :name, :state, :country
node(:full_name) { |city| city.full_name }
node(:new_record) { |city| city.new_record? }
if @city.errors.any?
node(:errors) { |city| city.errors.full_messages.join(', ') }
end
# 6. DistrictsController
class DistrictsController < ApplicationController
before_filter :load_city, :except => :show
respond_to :html, :json
...
# This method responds to Autocomplete. In this case, districts are
# shown in the parent city view, so the index template is not needed
# for html views as it is in the case of Cities (so no need for a `lookup` action)
def index
@districts = @city.districts.order(:name).where( "name like ?", "%#{params[:term]}%" )
respond_with @districts do |format|
format.json { render :json => @districts.map(&:name) }
end
end
# This responds to JS requests from the location selector.
def create
@district = @city.districts.find_or_init_or_create_by_name( params[:name], params[:create] )
associate_object if @district.valid? && !@district.new_record?
respond_with @district
end
private
# This loads cities that districts are nested under.
def load_city
@city = City.find( params[:city_id] )
end
# This makes the association between a district and a given object.
# Again this isn't my favorite part, but it works.
def associate_object
if params[:object] == 'Photo' && params[:object_id]
@district.photos << Photo.find(params[:object_id])
elsif params[:object] == 'User' && params[:object_id]
@district.users << User.find(params[:object_id])
end
end
...
end
# 7. District JSON Template (rabl)
# This is the JSON template the Districts Controller Responds with.
object @district
attributes :id, :name, :city_id
node(:new_record) { |district| district.new_record? }
node(:city_name) { |district| district.city.name }
# 8. City Model and Spec
# This class does a lot, but the specs explain it pretty well so I'll mostly
# leave the description there. The main thing is it uses Ruby Geocoder to
# make Google Maps API calls to convert strings into locations.
class City < Place
has_many :districts
has_many :photos
has_many :users
validates_presence_of :name, :country
validate :country_code_exists
validate :state_code_exists
validates_presence_of :state, :if => :north_american?
validates_length_of :name, :minimum => 3
validates_uniqueness_of :name, :scope => [:state, :country], :if => :north_american?
validates_uniqueness_of :name, :scope => :country, :unless => :north_american?
scope :with_photos, includes(:photos).where{ photos.city_id.not_eq nil }
scope :without_photos, includes(:photos).where( :photos => {:city_id=>nil} )
extend FriendlyId
friendly_id :full_name, :use => :slugged
geocoded_by :full_name, :latitude => :lat, :longitude => :lng
before_save :geocode unless Rails.env.test?
def to_s
name
end
def full_name
if north_american?
[name, state].join(', ')
elsif country.present?
[name, Carmen.country_name( country )].join(', ')
else
nil
end
end
def north_american?
['US','CA'].include? country
end
def self.find_by_string( string )
result = Geocoder.search( string )[0]
if result
if ['US','CA'].include?(result.country_code)
city = self.find_or_initialize_by_name_and_state_and_country( result.city, result.state_code, result.country_code )
else
city = self.find_or_initialize_by_name_and_country( result.city, result.country_code )
end
else
city = City.new
end
end
def self.find_or_init_or_create_by_string( string, create=false )
city = self.find_by_string( string )
if city.new_record? && create
city.save
end
return city
end
def state=(input)
write_attribute(:state, input.to_s.upcase)
end
def country=(input)
write_attribute(:country, input.to_s.upcase)
end
private
def country_code_exists
errors.add(:country, "Invalid Country Code") unless Carmen.country_codes.include? country
end
def state_code_exists
list ||= Carmen.state_codes('US') + Carmen.state_codes('CA')
if north_american?
errors.add(:state, "Invalid State Code") unless list.include? state
end
end
def self.country_for_state( state )
if Carmen.state_codes('US').include? state
'US'
elsif Carmen.state_codes('CA').include? state
'CA'
else
nil
end
end
def should_generate_new_friendly_id?
name.present? && new_record?
end
end
# 9. City Spec
# This spec describes the City model.
require 'spec_helper'
describe City do
let(:user) { Fabricate :user }
let(:city) { Fabricate :city }
let(:houston) { City.new(:name => "Houston", :state => "TX", :country => "US") }
let(:toronto) { City.new(:name => "Toronto", :state => "ON", :country => "CA") }
let(:london) { City.new(:name => "London", :country => "GB" ) }
describe "associations" do
it "should have a districts association" do
city.districts.should be_empty
end
it "should have districts" do
district = city.districts.create(:name => "Example")
district.city.should == city
end
it "should find only cities with photos" do
city1 = Fabricate :city
city2 = Fabricate :city
photo = Fabricate :photo
photo.city = city2
photo.save
City.with_photos.include?(city2).should be_true
City.with_photos.include?(city1).should_not be_true
end
end
describe "validations" do
it "should have only one of the same name per state" do
other_city = City.new(:name => city.name, :state => city.state, :country => city.country)
other_city.should_not be_valid
end
it "should only have one of the same name per country outside of north america" do
city = City.create(:name => "Barville", :country => "GB")
other_city = City.new(:name => "Barville", :country => "GB")
other_city.should_not be_valid
end
it "should require states if the country is US" do
city = City.new(:name => "Barville", :country => "US")
city.should_not be_valid
end
it "should require provinces if the country is CA" do
city = City.new(:name => "Barville", :country => "CA")
city.should_not be_valid
end
it "should not require states if country is not US or CA" do
city = City.new(:name => "Barville", :country => "GB")
city.should be_valid
end
it "should require countries to be the two letter country code" do
good_city = City.new(:name => "Barville", :country => "Great Britain")
good_city.should_not be_valid
end
it "should not accept bogus country codes" do
bad_city = City.new(:name => "Barville", :country => "ZZ")
bad_city.should_not be_valid
end
end
describe "display" do
it "should return name when coerced to string" do
houston.to_s.should == "Houston"
end
it "should return full_name like Houston, TX for US cities" do
houston.full_name.should == "Houston, TX"
end
it "should return full_name like Toronto, ON for CA cities" do
toronto.full_name.should == "Toronto, ON"
end
it "should return full_name like London, United Kingdom for international cities" do
london.full_name.should == "London, United Kingdom"
end
end
describe "input" do
it "should upcase state and city" do
city = City.new( :name => "Austin", :state => "tx", :country => "us" )
city.state.should == "TX"
city.country.should == "US"
end
end
describe "location" do
it "should identify North American cities" do
houston.north_american?.should be_true
toronto.north_american?.should be_true
end
it "should identify European cities as not North American" do
london.north_american?.should_not be_true
end
end
describe "find by string" do
it "should work for major international cities" do
city = City.find_by_string("London")
city.new_record?.should be_true
city.name.should == "London"
city.country.should == "GB"
end
it "should work for minor international cities" do
city = City.find_by_string("Castiglion Fiorentino, IT")
city.new_record?.should be_true
city.name.should == "Castiglion Fiorentino"
city.country.should == "IT"
end
it "should assume the larger city" do
city = City.find_by_string("Paris")
city.new_record?.should be_true
city.name.should == "Paris"
city.country.should == "FR"
end
it "should find smaller cities with help" do
city = City.find_by_string("Paris, TX")
city.new_record?.should be_true
city.name.should == "Paris"
city.state.should == "TX"
city.country.should == "US"
end
end
describe "states" do
it "should ignore state names in europe" do
a = City.create :name => "London", :country => "GB"
b = City.new :name => "London", :state => "London", :country => "GB"
b.valid?.should_not be_true
end
end
describe "find or create by string" do
it "should find existing cities when the create arg is false" do
a = City.create :name => "London",:country=>"GB"
b = City.find_or_init_or_create_by_string("London")
b.should == a
end
it "should find existing cities when the create arg is true" do
a = City.create :name => "London",:country=>"GB"
b = City.find_or_init_or_create_by_string("London", true)
b.should == a
end
it "should ignore state names in european cities" do
a = City.create :name => "London", :state => "LO", :country=>"GB"
b = City.find_or_init_or_create_by_string("London")
b.should == a
end
it "should only initialize cities when the create arg is false" do
city = City.find_or_init_or_create_by_string("Paris, TX")
city.new_record?.should be_true
city.name.should == "Paris"
city.state.should == "TX"
city.country.should == "US"
end
it "should create cities when the create arg is true" do
city = City.find_or_init_or_create_by_string("Paris, TX", true)
city.new_record?.should be_false
city.name.should == "Paris"
city.state.should == "TX"
city.country.should == "US"
end
it "should create cities when the create arg is a true string" do
city = City.find_or_init_or_create_by_string("Paris, TX", "true")
city.new_record?.should be_false
city.name.should == "Paris"
city.state.should == "TX"
city.country.should == "US"
end
end
end
# 9. District Model and Spec
# This model is like City but simpler, it mostly just holds a name
# for neighborhoods. It is also described by its spec.
class District < Place
belongs_to :city
has_many :photos
has_many :users
validates_presence_of :city
validates_uniqueness_of :name, :scope => :city_id
validates_length_of :name, :minimum => 3
scope :with_photos, includes(:photos).where{ photos.district_id.not_eq nil }
scope :without_photos, includes(:photos).where( :photos => {:district_id=>nil} )
extend FriendlyId
friendly_id :name, :use => :scoped, :scope => :city
def to_s
name
end
def name=(input)
write_attribute(:name, input.to_s.titleize)
end
def self.find_or_init_or_create_by_name(name, create=false)
safe_name = name.to_s.titleize #For whatever reason the find or initialize function doesn't use the name= method.
district = self.find_or_initialize_by_name(safe_name)
if district.new_record? && create
district.save
end
return district
end
end
# This is the limit of how many files you can put in a Gist, so here is the
# last part, this is a spec to describe Districts.
require 'spec_helper'
describe District do
let(:district) { Fabricate :district }
let(:city) { Fabricate :city }
context "find, init, or create" do
it "should find existing records by name" do
city = district.city
dist2 = city.districts.find_or_init_or_create_by_name( district.name )
dist2.should == district
end
it "should initialize new records" do
dist = city.districts.find_or_init_or_create_by_name "Fooville"
dist.name.should == "Fooville"
dist.new_record?.should be_true
end
it "should create new records" do
dist = city.districts.find_or_init_or_create_by_name "Fooville", true
dist.name.should == "Fooville"
dist.new_record?.should be_false
end
it "should create new records with create arg passed by string" do
dist = city.districts.find_or_init_or_create_by_name "Fooville", "true"
dist.name.should == "Fooville"
dist.new_record?.should be_false
end
it "should not find a district with the same name and different city" do
city1 = Fabricate :city
dist1 = city1.districts.create(:name => "Barton")
city2 = Fabricate :city
dist2 = city2.districts.find_or_init_or_create_by_name "Barton"
dist2.new_record?.should be_true
dist2.should_not == dist1
end
end
context "names" do
it "should capitalize a one-word name on input" do
d = District.new :name => 'test'
d.name.should == 'Test'
end
it "should capitalize a two-word name on input" do
d = District.new :name => 'another test'
d.name.should == 'Another Test'
end
it "should find an existing district no matter the capitalization" do
d1 = city.districts.create :name => "Example"
d2 = city.districts.find_or_init_or_create_by_name "example"
d2.should == d1
end
end
end
# So, that's the full-stack of code for the location_selector widget.
# There are also stylesheets (.scss) and images that indicate status
# but that's pretty basic stuff.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment