lib/volt/models/url.rb

Summary

Maintainability
B
6 hrs
Test Coverage
# 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