lib/volt/models/url.rb
# The url class handles parsing and updating the url
require 'volt/reactive/reactive_accessors'
require 'volt/models/location'
require 'volt/utils/parsing'
module Volt
class URL
include ReactiveAccessors
# TODO: we need to make it so change events only trigger on changes
reactive_accessor :scheme, :host, :port, :path, :query, :fragment
attr_accessor :router, :routes_loader
def initialize(router = nil)
@router = router
@location = Location.new
end
def params
@params ||= Model.new({}, persistor: Persistors::Params)
end
# Parse takes in a url and extracts each sections.
# It also assigns and changes to the params.
def parse(url)
if url[0] == '#'
# url only updates fragment
self.fragment = url[1..-1]
update!
else
host = location.host
protocol = location.protocol
if url !~ /[:]\/\//
# Add the host for local urls
url = protocol + "//#{host}" + url
else
# Make sure its on the same protocol and host, otherwise its external.
if url !~ /#{protocol}\/\/#{host}/
# Different host, don't process
return false
end
end
matcher = url.match(/^(#{protocol[0..-2]})[:]\/\/([^\/]+)(.*)$/)
self.scheme = matcher[1]
self.host, port = matcher[2].split(':')
self.port = (port || 80).to_i
path = matcher[3]
path, fragment = path.split('#', 2)
path, query = path.split('?', 2)
self.path = path
self.fragment = fragment
self.query = query
result = assign_query_hash_to_params
end
# Wait until things are rendered before we scroll
Volt::Timers.next_tick do
scroll
end
result
end
# Full url rebuilds the url from it's constituent parts.
# The params passed in are used to generate the urls.
def url_for(params)
host_with_port = host || location.host
host_with_port += ":#{port}" if port && port != 80
scheme = scheme || location.scheme
unless RUBY_PLATFORM == 'opal'
# lazy load routes and views on the server
if !@router && @routes_loader
# Load the templates
@routes_loader.call
end
end
path, params = @router.params_to_url(params)
if path == nil
raise "No route matched, make sure you have the base route defined last: `client '/', {}`"
end
new_url = "#{scheme}://#{host_with_port}#{path.chomp('/')}"
# Add query params
params_str = ''
unless params.empty?
query_parts = []
nested_params_hash(params).each_pair do |key, value|
# remove the _ from the front
value = Volt::Parsing.encodeURI(value)
query_parts << "#{key}=#{value}"
end
if query_parts.size > 0
query = query_parts.join('&')
new_url += "?#{query}"
end
end
# Add fragment
frag = fragment
new_url += '#' + frag if frag.present?
new_url
end
def url_with(passed_params)
url_for(params.to_h.merge(passed_params))
end
# Called when the state has changed and the url in the
# browser should be updated
# Called when an attribute changes to update the url
def update!
if Volt.in_browser?
new_url = url_for(params.to_h)
# Push the new url if pushState is supported
# TODO: add fragment fallback
`
if (document.location.href != new_url && history && history.pushState) {
history.pushState(null, null, new_url);
}
`
end
end
def scroll
if Volt.in_browser?
frag = fragment
if frag.present?
# Scroll to anchor via http://www.w3.org/html/wg/drafts/html/master/browsers.html#scroll-to-fragid
# Sometimes the fragment will cause a jquery parsing error, so we
# catch any exceptions.
`
try {
var anchor = $('#' + frag);
console.log('frag', anchor);
if (anchor.length == 0) {
anchor = $('*[name="' + frag + '"]:first');
}
if (anchor && anchor.length > 0) {
$(document.body).scrollTop(anchor.offset().top);
}
}
catch(e) {}
`
else
# Scroll to the top by default
`$(document.body).scrollTop(0);`
end
end
end
private
attr_reader :location
# Assigning the params is tricky since we don't want to trigger changed on
# any values that have not changed. So we first loop through all current
# url params, removing any not present in the params, while also removing
# them from the list of new params as added. Then we loop through the
# remaining new parameters and assign them.
def assign_query_hash_to_params
# Get a nested hash representing the current url params.
query_hash = parse_query
# Get the params that are in the route
new_params = @router.url_to_params(path)
fail "no routes match path: #{path}" if new_params == false
return false if new_params == nil
query_hash.merge!(new_params)
# Loop through the .params we already have assigned.
lparams = params
assign_from_old(lparams, query_hash)
assign_new(lparams, query_hash)
true
end
# Loop through the old params, and overwrite any existing values,
# and delete the values that don't exist in the new params. Also
# remove any assigned to the new params (query_hash)
def assign_from_old(params, new_params)
queued_deletes = []
params.attributes.each_pair do |name, old_val|
# If there is a new value, see if it has [name]
new_val = new_params ? new_params[name] : nil
if !new_val
# Queues the delete until after we finish the each_pair loop
queued_deletes << name
elsif new_val.is_a?(Hash)
assign_from_old(old_val, new_val)
else
# assign value
params.set(name, new_val) if old_val != new_val
new_params.delete(name)
end
end
queued_deletes.each { |name| params.delete(name) }
end
# Assign any new params, which weren't in the old params.
def assign_new(params, new_params)
new_params.each_pair do |name, value|
if value.is_a?(Hash)
assign_new(params.get(name), value)
else
# assign
params.set(name, value)
end
end
end
def parse_query
query_hash = {}
qury = query
if qury
qury.split('&').reject { |v| v == '' }.each do |part|
parts = part.split('=').reject { |v| v == '' }
parts[1] = Volt::Parsing.decodeURI(parts[1])
sections = query_key_sections(parts[0])
hash_part = query_hash
sections.each_with_index do |section, index|
if index == sections.size - 1
hash_part[section] = parts[1] # Last part, assign the value
else
hash_part = (hash_part[section] ||= {})
end
end
end
end
query_hash
end
# Splits a key from a ?key=value&... parameter into its nested
# parts. It also adds back the _'s used to access them in params.
# Example:
# user[name]=Ryan would parse as [:_user, :_name]
def query_key_sections(key)
key.split(/\[([^\]]+)\]/).reject(&:empty?)
end
# Generate the key for a nested param attribute
def query_key(path)
i = 0
path.map do |v|
i += 1
if i != 1
"[#{v}]"
else
v
end
end.join('')
end
def nested_params_hash(params, path = [])
results = {}
params.each_pair do |key, value|
unless value.nil?
if value.respond_to?(:persistor) && value.persistor && value.persistor.is_a?(Persistors::Params)
# TODO: Should be a param
results.merge!(nested_params_hash(value, path + [key]))
else
results[query_key(path + [key])] = value
end
end
end
results
end
end
end