# 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 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 scroll 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 host_with_port += ":#{port}" if port && port != 80 path, params = @router.params_to_url(params) 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); if (anchor.length == 0) { anchor = $('*[name="' + frag + '"]:first'); } if (anchor && anchor.length > 0) { console.log('scroll to: ', anchor.offset().top); $(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