├── Rakefile ├── app ├── assets │ └── javascripts │ │ ├── app.coffee │ │ ├── utilities │ │ ├── template.coffee │ │ ├── uuid.coffee │ │ ├── logger.coffee │ │ ├── virtual_class.coffee │ │ ├── binder.coffee │ │ ├── listener.coffee │ │ └── stack_trace.js │ │ ├── kindred.js │ │ └── models │ │ ├── setup.coffee │ │ ├── active_page.coffee │ │ └── base.coffee └── helpers │ └── template_helper.rb ├── lib ├── kindred │ ├── version.rb │ └── engine.rb └── kindred.rb ├── kindred-0.0.1.gem ├── Gemfile ├── .gitignore ├── LICENSE.txt ├── kindred.gemspec └── README.md /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | -------------------------------------------------------------------------------- /app/assets/javascripts/app.coffee: -------------------------------------------------------------------------------- 1 | window.App = {} 2 | -------------------------------------------------------------------------------- /lib/kindred/version.rb: -------------------------------------------------------------------------------- 1 | module Kindred 2 | VERSION = "0.0.6" 3 | end 4 | -------------------------------------------------------------------------------- /kindred-0.0.1.gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mfpiccolo/kindred/HEAD/kindred-0.0.1.gem -------------------------------------------------------------------------------- /lib/kindred.rb: -------------------------------------------------------------------------------- 1 | require "kindred/version" 2 | 3 | module Kindred 4 | require "kindred/engine" 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in kindred.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /app/assets/javascripts/utilities/template.coffee: -------------------------------------------------------------------------------- 1 | class App.Template 2 | @set_templates: (args) -> 3 | @template_info = args 4 | -------------------------------------------------------------------------------- /lib/kindred/engine.rb: -------------------------------------------------------------------------------- 1 | require 'rails' 2 | 3 | module Kindred 4 | module Rails 5 | class Engine < ::Rails::Engine 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.o 13 | *.a 14 | mkmf.log 15 | pkg 16 | -------------------------------------------------------------------------------- /app/assets/javascripts/utilities/uuid.coffee: -------------------------------------------------------------------------------- 1 | class App.UUID 2 | @generate = -> 3 | d = new Date().getTime() 4 | uuid = "xxxxxxxx-xxxx-4xxx-xxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) -> 5 | r = (d + Math.random() * 16) % 16 | 0 6 | d = Math.floor(d / 16) 7 | ((if c is "x" then r else (r & 0x7 | 0x8))).toString 16 8 | ) 9 | uuid 10 | -------------------------------------------------------------------------------- /app/assets/javascripts/kindred.js: -------------------------------------------------------------------------------- 1 | //= require app 2 | //= require ./utilities/binder 3 | //= require ./utilities/uuid 4 | //= require ./utilities/virtual_class 5 | //= require ./utilities/logger 6 | //= require ./utilities/listener 7 | //= require ./utilities/template 8 | //= require ./utilities/stack_trace 9 | //= require ./models/setup 10 | //= require ./models/active_page 11 | //= require ./models/base 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/assets/javascripts/utilities/logger.coffee: -------------------------------------------------------------------------------- 1 | class App.Logger 2 | @add_error: (error_object) -> 3 | @errors ||= [] 4 | @errors.push(error_object) 5 | 6 | jQueryInit = $.fn.init 7 | $.fn.init = (selector, context) -> 8 | element = new jQueryInit(selector, context) 9 | if selector and element.length is 0 10 | App.Logger.add_error({selector_not_found: selector, stack_trace: printStackTrace()}) 11 | 12 | element 13 | -------------------------------------------------------------------------------- /app/assets/javascripts/utilities/virtual_class.coffee: -------------------------------------------------------------------------------- 1 | App.VirtualClass = (classes...)-> 2 | classes.reduceRight (Parent, Child)-> 3 | class Child_Projection extends Parent 4 | constructor: -> 5 | # Temporary replace Child.__super__ and call original `constructor` 6 | child_super = Child.__super__ 7 | Child.__super__ = Child_Projection.__super__ 8 | Child.apply @, arguments 9 | Child.__super__ = child_super 10 | 11 | # If Child.__super__ not exists, manually call parent `constructor` 12 | unless child_super? 13 | super 14 | 15 | # Mixin prototype properties, except `constructor` 16 | for own key of Child:: 17 | if Child::[key] isnt Child 18 | Child_Projection::[key] = Child::[key] 19 | 20 | # Mixin static properties, except `__super__` 21 | for own key of Child 22 | if Child[key] isnt Object.getPrototypeOf(Child::) 23 | Child_Projection[key] = Child[key] 24 | 25 | Child_Projection 26 | -------------------------------------------------------------------------------- /app/assets/javascripts/utilities/binder.coffee: -------------------------------------------------------------------------------- 1 | App.DataBinder = (object_id, model_name) -> 2 | # Use a jQuery object as simple PubSub 3 | pubSub = jQuery({}) 4 | 5 | # We expect a `data` element specifying the binding 6 | # in the form: data-bind-="" 7 | data_attr = "input" 8 | message = object_id + ":change" 9 | 10 | jQuery(document).on "keydown", "[data-input]", (evt) -> 11 | $input = jQuery(this) 12 | pubSub.trigger message, [ 13 | $input.data(data_attr) 14 | $input.val() 15 | ] 16 | 17 | # PubSub propagates changes to all bound elements, setting value of 18 | # input tags or HTML content of other tags 19 | pubSub.on message, (evt, prop_name, new_val, id) -> 20 | # model_name = "purchase-order" 21 | model_elem = jQuery("[data-model-" + model_name + "=" + object_id + "]") 22 | 23 | attr_elm = model_elem.find("[data-attr=" + prop_name + "]") 24 | input_elm = $("[data-input=" + prop_name + "]") 25 | 26 | input_elm.val new_val 27 | attr_elm.html new_val 28 | 29 | pubSub 30 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Mike Piccolo 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /kindred.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'kindred/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "kindred" 8 | spec.version = Kindred::VERSION 9 | spec.authors = ["Mike Piccolo"] 10 | spec.email = ["mpiccolo@newleaders.com"] 11 | spec.summary = %q{Kindred is an open source project that intends to optimize programmers happiness and productivity for client-heavy rails applications. Kindred aims to allow developers to create robust client side applications with minimal code while maintaining best practices and conventions.} 12 | spec.description = %q{Javascript framework built for Rails} 13 | spec.homepage = "" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_runtime_dependency "rails", ">= 3.0" 22 | spec.add_runtime_dependency "happy_place", ">= 0.0.6" 23 | 24 | spec.add_development_dependency "bundler", "~> 1.7" 25 | spec.add_development_dependency "rake", "~> 10.0" 26 | end 27 | -------------------------------------------------------------------------------- /app/assets/javascripts/utilities/listener.coffee: -------------------------------------------------------------------------------- 1 | class App.Listener 2 | # Create a closure so that we can define intermediary 3 | # method pointers that don't collide with other items 4 | # in the global name space. 5 | (-> 6 | # Store a reference to the original remove method. 7 | originalOnMethod = jQuery.fn.on 8 | 9 | # Define overriding method. 10 | jQuery.fn.on = -> 11 | if (jQuery.type( arguments[0] ) == "string") 12 | listener_function = arguments[2] 13 | element = arguments[1] 14 | 15 | listener_namespace = arguments[0].split(".") 16 | event_trigger = listener_namespace[0] 17 | listener_name = listener_namespace[listener_namespace.length - 1] 18 | namespaces = listener_namespace.slice(1, -1) 19 | 20 | listener_info = { 21 | trigger: event_trigger, 22 | element: element, 23 | name: listener_name, 24 | funct: listener_function 25 | } 26 | 27 | App.Listener.createNestedObject(App, namespaces, listener_info) 28 | 29 | # Execute the original method. 30 | originalOnMethod.apply this, arguments 31 | 32 | else 33 | originalOnMethod.apply this, arguments 34 | )() 35 | 36 | @createNestedObject = (base, names, value) -> 37 | 38 | # If a value is given, remove the last name and keep it for later: 39 | lastName = (if arguments.length is 3 then names.pop() else false) 40 | 41 | # Walk the hierarchy, creating new objects where needed. 42 | # If the lastName was removed, then the last object is not set yet: 43 | i = 0 44 | 45 | while i < names.length 46 | base = base[names[i]] = base[names[i]] or {} 47 | i++ 48 | 49 | # If a value was given, set it to the last name: 50 | if Array.isArray(base[lastName]) 51 | base[lastName].push(value) 52 | else 53 | base[lastName] = [value] 54 | 55 | # Return the last object in the hierarchy: 56 | base 57 | 58 | @params: (obj) -> 59 | # Stupid hack because jQuery converts data to camelCase 60 | keys = Object.keys(obj) 61 | n = keys.length 62 | newobj = {} 63 | while n-- 64 | key = keys[n] 65 | 66 | if keys[n] == "kUuid" 67 | snake_key = "uuid" 68 | else 69 | snake_key = key.replace(/([A-Z])/g, ($1) -> 70 | "_" + $1.toLowerCase() 71 | ) 72 | newobj[snake_key] = obj[key] 73 | 74 | newobj 75 | -------------------------------------------------------------------------------- /app/helpers/template_helper.rb: -------------------------------------------------------------------------------- 1 | module TemplateHelper 2 | def template(model: nil, collection: nil, target_uuid: nil, meta: nil, &block) 3 | model_name = model 4 | 5 | @kindred_hash ||= {} 6 | @kindred_hash.merge!({ 7 | model_name => { 8 | template: capture(&block), 9 | collection: collection, 10 | target_uuid: target_uuid, 11 | meta: meta 12 | } 13 | }) 14 | self.controller.instance_variable_set(:@kindred_hash, @kindred_hash) 15 | return nil 16 | end 17 | 18 | def target(object) 19 | "data-target data-target-uuid=" + k_try(object, :uuid).to_s 20 | end 21 | 22 | def k_content_tag(element, attribute = nil, object = nil, content_or_options_with_block = nil, options = {}, escape = true, &block) 23 | content_tag(element, nil, options.merge({data: { attr: attribute, k_uuid: k_try(object, :uuid), val: object.try(attribute.to_sym)} })) 24 | end 25 | 26 | def k_hidden_field_tag(name, value=nil, object=nil, delegate_to=nil, options = {}) 27 | hidden_field_tag name, value, options.merge({data: { attr: name, k_uuid: k_try(object, :uuid), val: value } }) 28 | end 29 | 30 | def k_text_field_tag(object, attribute, options={}) 31 | text_field_tag attribute, nil, options.merge({data: { attr: attribute, k_uuid: k_try(object, :uuid), val: "" } }) 32 | end 33 | 34 | def k_check_box_tag(object, name, value=nil, checked = false, options = {}) 35 | check_box_tag name, value, checked, options.merge({data: { attr: name, k_uuid: k_try(object, :uuid), val: ""} }) 36 | end 37 | 38 | def k_select_tag(object, name, option_tags = nil, options = {}) 39 | select_tag name, option_tags, options.merge(data: { attr: name, k_uuid: k_try(object, :uuid), val: "" }) 40 | end 41 | 42 | def error_for(object, attribute) 43 | tag("small", data: {error: "", k_uuid: '', attr: attribute}, class: "error") 44 | end 45 | 46 | def kindred_model_data 47 | "
".html_safe 48 | end 49 | 50 | def k_try(object, method) 51 | unless object.is_a?(Symbol) 52 | object.try method 53 | end 54 | end 55 | 56 | def kindred_setup 57 | setup = "" 58 | setup << kindred_model_data 59 | setup << <<-eos 60 | 63 | eos 64 | setup.html_safe 65 | end 66 | 67 | end 68 | -------------------------------------------------------------------------------- /app/assets/javascripts/models/setup.coffee: -------------------------------------------------------------------------------- 1 | class App.Setup 2 | constructor: (@opts, meta_data) -> 3 | @_set_name_properties() 4 | @_set_self() 5 | 6 | @opts ||= {} 7 | 8 | if meta_data? 9 | @meta = meta_data["meta"] 10 | 11 | @attributes = {} 12 | 13 | # Allows for templates to either be passed in through the constructor or 14 | # to be set using the class level template 15 | # Example: 16 | # li = new App.LineItem({template: "
bunch of html>
"}) 17 | # Or 18 | # App.LineItem.template = "
bunch of html>
" 19 | template = @opts.template || App[@constructor.name].template 20 | 21 | @_set_opts_to_attributes() 22 | 23 | @_setup_route() 24 | 25 | @uuid = @opts.uuid || @attributes.uuid || App.UUID.generate() 26 | @id = @opts.id || @attributes.id 27 | @target_uuid = @opts.target_uuid || @attributes.target_uuid 28 | 29 | @attributes["uuid"] = @uuid 30 | 31 | if template? 32 | @template = template.replace(/\b(data-k-uuid)\.?[^\s|>]+/g, "data-k-uuid=" + @uuid) 33 | @template = @template.replace(/\b(data-id)\.?[^\s|>]+/g, "data-id=" + @id) 34 | @_build_attrs_template() 35 | @_setup_interpolated_vars() 36 | 37 | # Setus up the the different name strings 38 | # ClassName 39 | # class-name 40 | # class_name 41 | @set_class_name: (class_name) -> 42 | @class_name = class_name 43 | @dash_name = @_get_dash_name(class_name) 44 | @snake_name = @_get_snake_name(class_name) 45 | 46 | @_get_class_name: -> 47 | @name 48 | 49 | @_get_dash_name: (name) -> 50 | dash_str = name.replace /([A-Z])/g, ($1) -> 51 | "-" + $1.toLowerCase() 52 | dash_str[1 .. dash_str.length - 1] 53 | 54 | @_get_snake_name: (name) -> 55 | under_str = name.replace /([A-Z])/g, ($1) -> 56 | "_" + $1.toLowerCase() 57 | under_str[1 .. under_str.length - 1] 58 | 59 | _set_self: -> 60 | @_self = @ 61 | 62 | _set_name_properties: => 63 | @class_name ||= App[@constructor.name].class_name 64 | @dash_name = App[@constructor.name]._get_dash_name(@class_name) 65 | @snake_name = App[@constructor.name].snake_name 66 | 67 | # Sets up a template string with initial values 68 | _build_attrs_template: -> 69 | $.each @attributes, (attr, val) => 70 | j_attr = $(@template).find("[data-k-uuid='" + @uuid + "'][data-attr='" + attr + "']") 71 | clone = j_attr.clone() 72 | 73 | replace_string = clone.wrap('').parent().html() 74 | 75 | cloned_template = $(@template).clone() 76 | updated_template = $("
").append($(@template).clone()).html() 77 | 78 | if replace_string? && replace_string.length 79 | replace_regex = new RegExp(replace_string) 80 | 81 | new_attr_string = replace_string.replace(/\b(data-val)\.?[^\s]+/g, "data-val='" + val + "'") 82 | 83 | @template = updated_template.replace(replace_regex, new_attr_string) 84 | 85 | # Finds '{{}}' in the string template and replaces it with the attribute that 86 | # is that is declared 87 | # Example: 88 | # template = "
{{line_item.foo}}
" 89 | # li = new App.LineItem({foo: "bar"}) 90 | # li.template # => "
bar
" 91 | _setup_interpolated_vars: => 92 | template_match = new RegExp(/{{([^{}]+)}}/g) 93 | 94 | new_template = @template.replace(template_match, (match, p1) => 95 | class_name = match.split(".")[0].substr(2) 96 | attribute = match.split(".")[1].slice(0, - 2) 97 | 98 | if class_name == @snake_name && (typeof @get(attribute) != 'undefined') 99 | @get(attribute) 100 | else 101 | match 102 | ) 103 | 104 | @template = new_template 105 | 106 | _set_opts_to_attributes: -> 107 | if @opts? 108 | $.each @opts, (key, val) => 109 | @set key, val 110 | 111 | # Sets up the route with the ability to interpolate attributes into the route 112 | # Example: 113 | # class App.Box extends App.Base 114 | # @route = "invoices/{{invoice_id}}/line_items" 115 | # @set_class_name("LineItem") 116 | _setup_route: => 117 | url_match = new RegExp(/{{([^{}]+)}}/g) 118 | @route ||= App[@constructor.name].route 119 | @route ||= "/" + @snake_name + "s" 120 | @route = @route.replace(url_match, (match, p1) => 121 | attribute = match.slice(2, - 2) 122 | @get(attribute) 123 | ) 124 | -------------------------------------------------------------------------------- /app/assets/javascripts/models/active_page.coffee: -------------------------------------------------------------------------------- 1 | class App.ActivePage 2 | 3 | # This class method will with find all the models on the dom that are of a 4 | # particular class. Exapmle: 5 | # `App.LineItem.collection_from_page() # => [LineItemObject, LineItemObject] 6 | @collection_from_page: -> 7 | indices = $("[data-kindred-model]").find("[data-k-uuid][data-class='#{@snake_name}']") 8 | uuids = [] 9 | 10 | indices.map (i, tag) -> 11 | uuids.push($(tag).data("k-uuid")) 12 | 13 | # map args for JS array seem to differ from jQuery array above 14 | collection_attrs = uuids.map (uuid, i) => 15 | new @({uuid: uuid}).assign_attributes_from_page().attributes 16 | 17 | # If a model has a target_uuid and there is a corresponding element on the dom 18 | # with data-target and data-target-uuid="" this method 19 | # will append that models template to the target element. 20 | append_to_page: -> 21 | $template = $(@template) 22 | $.each @attributes, (key, value) => 23 | input = $template.find("input[data-attr='" + key + "']") 24 | if input.length 25 | if input.is(':checkbox') 26 | input.prop('checked', value) 27 | else 28 | input.val(value) 29 | 30 | select = $template.find("select[data-attr='" + key + "']") 31 | if select.length 32 | select.val(value) 33 | 34 | display = $template.find("div[data-attr='" + key + "'], span[data-attr='" + key + "'], p[data-attr='" + key + "']") 35 | if display.length 36 | new_display = display.html(value) 37 | display.replaceWith(new_display) 38 | 39 | @_append_data_model_to_page() 40 | 41 | $("[data-target][data-target-uuid='" + @target_uuid + "']").append($template) 42 | 43 | error_tag = $("[data-error][data-k-uuid='" + @uuid + "']") 44 | error_tag.hide() 45 | 46 | # Removes the wrapper element and the model from the data-kindred-model store. 47 | remove_from_page: -> 48 | $("[data-wrapper][data-k-uuid='" + @uuid + "']").remove() 49 | $("[data-kindred-model]").find("div[data-k-uuid='" + @uuid + "']").remove() 50 | 51 | # Iterates through a modeals attributes and updates the values of the inputs or 52 | # selects based on the models attributes 53 | update_vals_on_page: -> 54 | $.each @attributes, (attr, val) => 55 | $("[data-k-uuid='" + @uuid + "'][data-attr='" + attr + "']").val(val) 56 | 57 | # A display attribute can be wrapped in a div, p, or span tag. This method 58 | # will replace the contents of these tags with the corrosponding attributes 59 | # value 60 | update_displays_on_page: -> 61 | $.each @attributes, (attr, val) => 62 | $("div[data-k-uuid='" + @uuid + "'][data-attr='" + attr + "'], span[data-k-uuid='" + @uuid + "'][data-attr='" + attr + "'], p[data-k-uuid='" + @uuid + "'][data-attr='" + attr + "']").html(val) 63 | 64 | # Updtaes the meta data on the dom 65 | update_meta_on_page: -> 66 | $("[data-kindred-model]").find("div[data-k-uuid='" + @uuid + "']").data("meta", @meta) 67 | 68 | # Boolean method that will check if a page representation of a model is dirty 69 | # by checking if any of the values of the inputs and selects differ from the ones 70 | # originally added to the page 71 | dirty_from_page: -> 72 | dirty = [] 73 | $.each $("input[data-k-uuid='" + @uuid + "'], select[data-k-uuid='" + @uuid + "']"), (i, input) => 74 | $input = $(input) 75 | 76 | dirty_object = {} 77 | attr = $input.data("attr") 78 | 79 | if @_input_dirty($input) 80 | dirty.push(dirty_object[attr] = [$input.data("val").toString(), $input.val().toString()]) 81 | 82 | if dirty.length 83 | true 84 | else 85 | false 86 | 87 | # This method will pull all the inputs with the specific model uuid and assign 88 | # the values to attributes. 89 | # Example: 90 | # html: # user entered "bar" 91 | # js: li = App.LineItem.new({uudi: "long-uuid"}); 92 | # li.assign_attributes_from_page() 93 | # li.attributes # => {uuid: "long-uuid", foo: "bar"} 94 | # li.get("foo") # => "bar" 95 | assign_attributes_from_page: -> 96 | $("input[data-k-uuid='" + @uuid + "']").each (i, input) => 97 | $input = $(input) 98 | 99 | if $input.is(':checkbox') 100 | @set $input.data("attr"), $input.prop('checked') 101 | else 102 | @set $input.data("attr"), $input.val() 103 | 104 | model_data = $("[data-kindred-model]").find("[data-k-uuid='" + @uuid + "']") 105 | if !isNaN(parseFloat(model_data.data("id"))) && isFinite(model_data.data("id")) 106 | @id = model_data.data("id") 107 | 108 | @meta = model_data.data("meta") 109 | 110 | $("select[data-k-uuid='" + @uuid + "']").each (i, select) => 111 | @set $(select).data("attr"), $(select).val() 112 | 113 | @ 114 | 115 | # Cleans up error elements. This method is only useful if you are not using 116 | # the wrapper element. 117 | remove_errors_from_page: -> 118 | $("[data-error][data-k-uuid='" + @uuid + "']").each (i, elem) => 119 | $(elem).remove() 120 | 121 | # Used to update the data-val values when returned from a server. 122 | _update_data_vals_on_page: -> 123 | model_data = $("[data-kindred-model]").find("[data-k-uuid='" + @uuid + "']") 124 | model_data.data("id", @id) 125 | $.each @attributes, (attr, val) => 126 | $("[data-k-uuid='" + @uuid + "'][data-attr='" + attr + "']").data("val", val) 127 | 128 | # Adds a model to the data model on page store. This should probably be converted 129 | # over to using local storage or some other browser store. 130 | _append_data_model_to_page: -> 131 | model_div = "
" 132 | $("[data-kindred-model]").append(model_div) 133 | 134 | # Checks if a particular jquery inputs data differes from the data-val 135 | _input_dirty: (input) -> 136 | if input.is("select") && input.data("val").length == 0 137 | false 138 | else if input.is(":checkbox") 139 | !(input.data("val").toString() == input.prop("checked").toString()) 140 | else 141 | !(input.data("val").toString() == input.val().toString()) 142 | 143 | _stringified_meta: -> 144 | if @meta? 145 | JSON.stringify(@meta) 146 | else 147 | "" 148 | -------------------------------------------------------------------------------- /app/assets/javascripts/models/base.coffee: -------------------------------------------------------------------------------- 1 | class App.Base extends App.VirtualClass App.ActivePage, App.Setup 2 | 3 | @set_template: (template) -> 4 | @template = template 5 | 6 | # This method is a template of how a save all would work. If your implementation 7 | # is basic then this logic might pass but most apps will have complex batch 8 | # persistence logic. In that case you should overwrite this method in your model 9 | # or create a javascript service that handles batch persistence 10 | @save_all: (opts) -> 11 | data = {} 12 | 13 | # TODO fix the naive inflection 14 | collection = @collection_from_page(@snake_name) 15 | added_attrs = [] 16 | 17 | $.each collection, (i, attrs) -> 18 | added_attrs.push($.extend attrs, opts.add_data_to_each) 19 | 20 | data[@snake_name + "s"] = added_attrs 21 | 22 | url_match = new RegExp(/{{([^{}]+)}}/g) 23 | @route = @route.replace(url_match, (match, p1) => 24 | attribute = match.slice(2, - 2) 25 | opts.add_data_to_each[attribute] 26 | ) 27 | 28 | path = @route + "/save_all.json" 29 | method = 'PUT' 30 | 31 | $.ajax 32 | type: method 33 | url: App.BaseUrl + "/" + path 34 | data: data 35 | 36 | success: (data, textStatus, xhr) => 37 | @after_save_all(data, textStatus, xhr) 38 | error: (xhr) => 39 | @after_save_all_error(xhr) 40 | 41 | # Example: 42 | # li = new App.LineItem() 43 | # li.set("foo", "bar") 44 | # li.attributes # => {"foo": "bar"} 45 | set: (attr_name, val) -> 46 | @attributes[attr_name] = val 47 | 48 | # Example: 49 | # li = new App.LineItem({foo: "bar"}) 50 | # li.get("foo") # => "bar" 51 | get: (attr_name) -> 52 | @attributes[attr_name] 53 | 54 | # Example: 55 | # li = new App.LineItem({foo: "bar"}) 56 | # li.remove("foo") 57 | # li.attributes # => {} 58 | remove: (attr_name) -> 59 | delete @attributes[attr_name] 60 | 61 | # Example: 62 | # li = new App.LineItem() 63 | # li.set_meta("foo", "bar") 64 | # li.meta # => {foo: "bar"} 65 | set_meta: (attr_name, val) -> 66 | @meta[attr_name] = val 67 | 68 | # Example: 69 | # li = new App.LineItem() 70 | # li.set_meta("foo", "bar") 71 | # li.remove_meta("foo") 72 | # li.meta # => {} 73 | remove_meta: (attr_name) -> 74 | delete @meta[attr_name] 75 | 76 | # POST or PUT will be used for the ajax request depending on the value of id. 77 | # TODO: meta data is not submitted with this request 78 | save: -> 79 | if !isNaN(parseFloat(@id)) && isFinite(@id) 80 | path = @route + "/" + @id + ".json" 81 | method = 'PUT' 82 | else 83 | path = @route + ".json" 84 | method = "POST" 85 | 86 | params = {} 87 | params[@snake_name] = @attributes 88 | 89 | response = $.ajax 90 | type: method 91 | url: App.BaseUrl + "/" + path 92 | dataType: "json" 93 | data: params 94 | global: false 95 | async: false 96 | success: (data, textStatus, xhr) => 97 | @after_save(data, textStatus, xhr) 98 | error: (xhr) => 99 | @after_save_error(xhr) 100 | 101 | # This method will send an ajax request if id is a number. Other wise it will 102 | # just call the after destroy callback. 103 | destroy: -> 104 | @route ||= @snake_name + "s" 105 | path = @route + "/" + @id + ".json" 106 | method = "DELETE" 107 | 108 | if !isNaN(parseFloat(@id)) && isFinite(@id) 109 | $.ajax 110 | type: method 111 | url: App.BaseUrl + "/" + path 112 | dataType: "json" 113 | global: false 114 | async: false 115 | success: (data, textStatus, xhr) => 116 | @after_destroy(data, textStatus, xhr) 117 | error: (xhr) => 118 | @after_destroy_error(xhr) 119 | else 120 | @after_destroy() 121 | 122 | # accepts an object and assigns the key value pairs to the attributes hash 123 | assign_attributes: (attrs) -> 124 | $.each attrs, (attr, val) => 125 | if attr == "id" && !isNaN(parseFloat(val)) && isFinite(val) 126 | @id = val 127 | @set(attr, val) 128 | 129 | #overridable hook 130 | after_save: (data, textStatus, xhr) -> 131 | @assign_attributes(data) 132 | @_clear_errors() 133 | @_update_data_vals_on_page() 134 | @update_vals_on_page() 135 | @_setup_interpolated_vars() 136 | @update_displays_on_page() 137 | 138 | #overridable hook 139 | after_save_error: (xhr) -> 140 | errors = JSON.parse(xhr.responseText) 141 | @_handle_errors(errors) 142 | 143 | #overridable hook 144 | after_destroy: (data, textStatus, xhr) -> 145 | @remove_errors_from_page() 146 | 147 | #overridable hook 148 | after_destroy_error: (xhr) -> 149 | 150 | #overridable hook 151 | @after_save_all: (data, textStatus, xhr) -> 152 | $(data).each (i, response_object) => 153 | attrs = response_object[@snake_name] 154 | model = new App[@class_name]({uuid: attrs["uuid"]}) 155 | model.assign_attributes(attrs) 156 | model._clear_errors() 157 | model._update_data_vals_on_page() 158 | 159 | #overridable hook 160 | @after_save_all_error: (xhr) -> 161 | data = JSON.parse(xhr.responseText) 162 | $(data).each (i, response_object) => 163 | unless $.isEmptyObject(response_object["errors"]) 164 | uuid = response_object[@snake_name].uuid 165 | model = new App[@class_name](response_object[@snake_name]) 166 | model.assign_attributes_from_page() 167 | model._handle_errors(response_object["errors"]) 168 | 169 | # Finds the attributes from a json response that do not have attached errors and 170 | # will hide the errors that have been resolved. 171 | # Then this method will iterate over the error responses and add the proper messaging 172 | # and show the error tags 173 | _handle_errors: (errors_obj, uuid) -> 174 | hideable_error_inputs = $(Object.keys(@attributes)).not(Object.keys(errors_obj)).get() 175 | $.each hideable_error_inputs, (i, attr) => 176 | $("[data-error][data-attr='" + attr + "'][data-k-uuid='" + @uuid + "']").hide() 177 | 178 | $.each errors_obj, (attr, messages) => 179 | error_tag = $("[data-error][data-attr='" + attr + "'][data-k-uuid='" + @uuid + "']") 180 | error_tag.html("") 181 | 182 | $.each messages, (i, message) -> 183 | error_tag.append("" + message + "
") 184 | 185 | error_tag.show() 186 | 187 | # hides all the errors 188 | _clear_errors: () -> 189 | $.each @attributes, (attr, val) => 190 | $("[data-error][data-attr='" + attr + "'][data-k-uuid='" + @uuid + "']").hide() 191 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | kindred 2 | ============ 3 | | Project | Gem Release | 4 | |------------------------ | ----------------- | 5 | | Gem name | kindred | 6 | | License | [MIT](LICENSE.txt) | 7 | | Version | [![Gem Version](https://badge.fury.io/rb/kindred.png)](http://badge.fury.io/rb/kindred) | 8 | | Continuous Integration | [![Build Status](https://travis-ci.org/mfpiccolo/kindred.png?branch=master)](https://travis-ci.org/mfpiccolo/kindred) 9 | | Test Coverage | [![Coverage Status](https://coveralls.io/repos/mfpiccolo/kindred/badge.png?branch=master)](https://coveralls.io/r/mfpiccolo/kindred?branch=coveralls) 10 | | Grade | [![Code Climate](https://codeclimate.com/github/mfpiccolo/kindred/badges/gpa.svg)](https://codeclimate.com/github/mfpiccolo/kindred) 11 | | Dependencies | [![Dependency Status](https://gemnasium.com/mfpiccolo/kindred.png)](https://gemnasium.com/mfpiccolo/kindred) 12 | | Homepage | [http://mfpiccolo.github.io/kindred][homepage] | 13 | | Documentation | [http://rdoc.info/github/mfpiccolo/kindred/frames][documentation] | 14 | | Issues | [https://github.com/mfpiccolo/kindred/issues][issues] | 15 | | Conversation | [![Gitter](https://badges.gitter.im/Join Chat.svg)](https://gitter.im/mfpiccolo/kindred?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 16 | 17 | ## Description 18 | Kindred is an open source project that intends to optimize programmers happiness and productivity for client-heavy rails applications. Kindred aims to allow developers to create robust client side applications with minimal code while maintaining best practices and conventions. 19 | 20 | ## Installation 21 | 22 | Add this line to your application's Gemfile: 23 | 24 | ```ruby 25 | gem "kindred" 26 | ``` 27 | 28 | And then execute: 29 | 30 | $ bundle 31 | 32 | Or install it yourself as: 33 | 34 | $ gem install kindred 35 | 36 | ## Getting Started with Rails 37 | 38 | Add `gem "kindred"` to gemfile 39 | 40 | Add `//= require kindred` to application.js manifest 41 | 42 | If you are using uglifier you may need to add the following to assets.rb: 43 | 44 | ```ruby 45 | Rails.application.config.assets.js_compressor = Uglifier.new(mangle: false) 46 | ``` 47 | 48 | The following config needs to be abstracted to the gem or refactored but for now: 49 | 50 | Add this configuration to application.html.erb: 51 | 52 | ``` 53 | <%= kindred_setup %> 54 | ``` 55 | 56 | In the controller that you will be rendering kindred models you need to add: 57 | 58 | ``` 59 | ... 60 | after_action :setup_kindred 61 | 62 | helper_method :js 63 | ... 64 | ``` 65 | 66 | ``` 67 | def setup_kindred 68 | view_context.content_for :kindred_script do 69 | js(js_class: "App.Template", function: "set_templates", args: @kindred_hash, rendered: true) 70 | end 71 | end 72 | ``` 73 | 74 | One again, much of this configuration will be removed in the future but is neccesary at the moment. 75 | 76 | 77 | ## Demo 78 | 79 | If you would like to see kindred in action check out [kindred-demo](https://kindred-demo.herokuapp.com/invoices/1/edit). 80 | 81 | ## Features 82 | ###Templates 83 | In a rails view you can pass html to your javascript by using the `#template` helper method. 84 | The `#template` method takes the following keyword arguments: 85 | 86 | `model:` String of the javascript model name 87 | 88 | `collection:` Collection of json serializable ruby objects 89 | 90 | `target_uuid:` String of the uuid for the data-target-uuid attribute wrapper 91 | 92 | `&block` In the block pass html that will be used for that model 93 | 94 | ```HTML 95 |
> 96 | <%= template(collection: @line_items, target: "line-item", model: "line_item") do %> 97 | 98 | <%= k_text_field_tag(:line_item, :description) %> 99 | <%= k_text_field_tag(:line_item, :qty) %> 100 | <%= k_text_field_tag(:line_item, :price_cents) %> 101 | <%= k_check_box_tag(:line_item, :complete) %> 102 | 103 | <% end %> 104 |
105 | ``` 106 | Templates will be available in your javascript by accessing the `App.Template` class. The `#template_info` property is an array of objects that contain both the collection and the template namespaced under the model that you passed. 107 | 108 | If you passed a line_item you could access it with: 109 | 110 | `li_info = App.Template.template_info["line_item"]` 111 | 112 | You could then get access to the collection or the template by using those properties. 113 | 114 | `li_info.template` would return the html you passed through 115 | 116 | `li_info.collection` would return the json collection 117 | 118 | ###Interpolation 119 | Inside of templates, it is possible to interpolate dynamic data by using the {{}} syntax. 120 | 121 | For example, you could interpolate the id of the line item by doing the following 122 | ```HTML 123 |
> 124 | <%= template(collection: @line_items, target: "line-item", model: "line_item") do %> 125 | 126 | {{line_item.id}} 127 | <%= k_text_field_tag(:line_item, :description) %> 128 | <%= k_text_field_tag(:line_item, :qty) %> 129 | <%= k_text_field_tag(:line_item, :price_cents) %> 130 | <%= k_check_box_tag(:line_item, :complete) %> 131 | 132 | <% end %> 133 |
134 | ``` 135 | 136 | ###Controllers 137 | In kindred, javascript controllers are a client side augmentation of your ruby controllers. They are just client side code that gets run on page load. 138 | 139 | ```coffeescript 140 | class this.InvoicesController 141 | @edit: -> 142 | console.log "Run this on invoice edit view" 143 | ``` 144 | 145 | To ensure that this code is run on the client side call the js method from your view or controller: 146 | 147 | ```ruby 148 | class InvoicesController < ApplicationController 149 | def edit 150 | @line_items = @invoice.line_items 151 | 152 | respond_to do |format| 153 | format.html { js } 154 | end 155 | end 156 | end 157 | ``` 158 | 159 | ###Models 160 | 161 | A kindred model is an object that helps the interaction between the page and the rails api. 162 | 163 | Here is an example model: 164 | 165 | ```coffeescript 166 | class App.LineItem extends App.Base 167 | 168 | @route = "/line_items" 169 | @set_class_name("LineItem") 170 | ``` 171 | 172 | ####Instance Functions 173 | 174 | #####Page functions: 175 | 176 | `append_to_page()` This function will put the values from the model instance that is called on the page. If the element found is an input it will add it as a value. If it is not an input it will insert the value into the tag. 177 | 178 | `dirty_from_page()` This boolean function will check all the inputs that belong to the model instance that it is being called on and check if the value has changed since it was set on the page. Returns true or false. 179 | 180 | `assign_attributes_from_page()` This will grab all the inputs that belong to a model instance and assign them to the attributes property as a javascript object. 181 | 182 | `remove_errors_from_page()` This function will remove all errors from the page belonging to the model instance. 183 | 184 | #####Base functions: 185 | 186 | `set(attr_name, val)` Assigns the value to the model instance attributes object using the attribute name as a key. 187 | 188 | ```coffeescript 189 | li = new App.LineItem() 190 | li.set("foo", "bar") 191 | li.attributes # => Object {uuid: "some-uuid", foo: "bar"} 192 | ``` 193 | 194 | `get(attr_name)` Retrieves the value from the model instance attributes object using the attribute name. 195 | 196 | ```coffeescript 197 | li = new App.LineItem({foo: "bar"}) 198 | li.get("foo") # => "bar" 199 | ``` 200 | 201 | `save()` ajax post request to the route specified in the model with the data from the model instance attributes object to either post or patch depending on the presence of the id. 202 | 203 | ```coffeescript 204 | li = new App.LineItem({foo: "bar"}) 205 | li.save() # => sends request to POST or PATCH depending on presince of id 206 | ``` 207 | 208 | ``` 209 | # Server log 210 | Started POST "/line_items.json" for 127.0.0.1 at 2014-12-14 02:35:32 -0800 211 | Processing by LineItemsController#create as JSON 212 | Parameters: {"line_item"=>{"uuid"=>"354f1fb8-a80a-449d-2320-e316bb02390c", "foo"=>"bar"}} 213 | ``` 214 | 215 | `destroy()` ajax delete request to the route specified in the model. 216 | 217 | ```coffeescript 218 | li = new App.LineItem({id: 1}) 219 | li.destroy() # => Removes element from the page and sends delete request if id present 220 | ``` 221 | 222 | ``` 223 | # Server log 224 | Started DELETE "/line_items/1.json" for 127.0.0.1 at 2014-12-14 02:41:39 -0800 225 | Processing by LineItemsController#destroy as JSON 226 | Parameters: {"id"=>"1"} 227 | ``` 228 | 229 | `assign_attributes(attrs)` Adds the attrs to the attributes object for the model instance. 230 | 231 | ```coffeescript 232 | li = new App.LineItem({foo: "bar"}) 233 | li.assign_attributes({baz: qux, quux: "corge"}) 234 | li.attributes # => Object {uuid: "some-uuid", foo: "bar", baz: "qux", quux: "corge"} 235 | ``` 236 | 237 | #####Base Overridable Hooks: 238 | These hooks have defalut functionality but you can override them in the model to do custom behavior. 239 | 240 | `after_save` 241 | 242 | `after_save_error` 243 | 244 | `after_destroy` 245 | 246 | `after_destroy_error` 247 | 248 | Here is an example where you are removing relevant errors from the page after deleting a line item. 249 | 250 | ```coffeescript 251 | class App.LineItem extends App.Base 252 | 253 | @route = "/line_items" 254 | @set_class_name("LineItem") 255 | 256 | # kindred override hook 257 | after_destroy: (data, textStatus, xhr) -> 258 | $("[data-error][data-k-uuid='" + @uuid + "']").parent().parent().remove() 259 | ``` 260 | 261 | ####Class Functions 262 | 263 | `set_template()` On page load, use this function to set the template for the model. 264 | (i.e. `App.LineItem.set_template App.Template.template_info["line_item"]`) 265 | 266 | `set_class_name()` Sets the class name for the model as well as dash_name and snake_name. 267 | 268 | `collection_from_page()` Retrieves a collection of model objects from the page. 269 | 270 | `save_all(opts)` Collects all the objects of this class from the page ajax posts the json to the save_all action. 271 | 272 | ###Listeners 273 | Listeners in kindred should be namespaces and set in classes. 274 | 275 | Below is an example of a listener that will both send a delete request to the server and remove the element from the page. 276 | 277 | ```coffeescript 278 | # app/assets/javascripts/listeners/invoice_listeners.coffee 279 | class App.InvoiceListeners extends App.Listener 280 | 281 | @set: -> 282 | $("#line-item-table").on "click.Listeners.LineItem.delete", ".delete", (evt) -> 283 | li = new App.LineItem({id: $(@).data("id"), uuid: $(@).data("k-uuid")}) 284 | li.destroy() 285 | $(@).parent().parent().remove() 286 | ``` 287 | 288 | A bonus for namespacing the listener is that you can see all the listeners that kindred has registerd using the `App.Listeners` class. 289 | 290 | `App.Listeners` will return an object which contains all the registered listeners and information about each listener. 291 | 292 | ### Error Logging 293 | 294 | All jquery element not found errors are logged to `App.Logger` 295 | 296 | `App.Logger.errors` will return an array of errors with information including stack traces. 297 | 298 | ### Naming and Directory Structure 299 | Although this is really up to the individual developer, Kindred should really be set up with a similar naming and directory stucture as rails. 300 | 301 | If you are adding code that is controller and action specific, then add a directory called controllers in your `app/assets/javascripts` directory. If your controllers are namespaced then namespace them just like you do in your rails controllers. Here is an example of a namespaced coffee class: 302 | 303 | ```coffeescript 304 | # app/assets/javascripts/controllers/admin/special/orders_controller.coffee 305 | @Admin ||= {}; 306 | @Admin.Special ||= {}; 307 | 308 | class @Admin.Special.OrdersController 309 | 310 | @index: (args) -> 311 | alert("Do some js stuff here...") 312 | ``` 313 | 314 | Put models in `app/assets/javascripts/models` 315 | 316 | ```coffeescript 317 | # app/assets/javascripts/models/some_model.coffee 318 | class App.SomeModel 319 | 320 | @route = "/some_models" 321 | @set_class_name("SomeModel") 322 | ``` 323 | 324 | Make note of the ||=. This is to make sure that you don't overwrite the js object if it already exists. 325 | 326 | Use this same naming and directory structure for all your js. If you are creating service objects then put them in `app/assets/javascripts/services` 327 | 328 | Remember to add your paths to the manifest so sprockets can load them: 329 | 330 | ``` 331 | //= require_tree ./controllers 332 | //= require_tree ./services 333 | ``` 334 | 335 | Or require them explicitly: 336 | 337 | `//= require controllers/admin/special/orders_controller` 338 | 339 | ## Donating 340 | Support this project and [others by mfpiccolo][gittip-mfpiccolo] via [gittip][gittip-mfpiccolo]. 341 | 342 | [gittip-mfpiccolo]: https://www.gittip.com/mfpiccolo/ 343 | 344 | ## Copyright 345 | 346 | Copyright (c) 2014 Mike Piccolo 347 | 348 | See [LICENSE.txt](LICENSE.txt) for details. 349 | 350 | ## Contributing 351 | 352 | 1. Fork it ( http://github.com/mfpiccolo/kindred/fork ) 353 | 2. Create your feature branch (`git checkout -b my-new-feature`) 354 | 3. Commit your changes (`git commit -am 'Add some feature'`) 355 | 4. Push to the branch (`git push origin my-new-feature`) 356 | 5. Create new Pull Request 357 | 358 | [![githalytics.com alpha](https://cruel-carlota.pagodabox.com/e1a155a07163d56ca0c4f246c7aa8766 "githalytics.com")](http://githalytics.com/mfpiccolo/kindred) 359 | 360 | [license]: https://github.com/mfpiccolo/kindred/MIT-LICENSE 361 | [homepage]: http://mfpiccolo.github.io/kindred 362 | [documentation]: http://rdoc.info/github/mfpiccolo/kindred/frames 363 | [issues]: https://github.com/mfpiccolo/kindred/issues 364 | 365 | -------------------------------------------------------------------------------- /app/assets/javascripts/utilities/stack_trace.js: -------------------------------------------------------------------------------- 1 | // Domain Public by Eric Wendelin http://www.eriwen.com/ (2008) 2 | // Luke Smith http://lucassmith.name/ (2008) 3 | // Loic Dachary (2008) 4 | // Johan Euphrosine (2008) 5 | // Oyvind Sean Kinsey http://kinsey.no/blog (2010) 6 | // Victor Homyakov (2010) 7 | /*global module, exports, define, ActiveXObject*/ 8 | (function(global, factory) { 9 | if (typeof exports === 'object') { 10 | // Node 11 | module.exports = factory(); 12 | } else if (typeof define === 'function' && define.amd) { 13 | // AMD 14 | define(factory); 15 | } else { 16 | // Browser globals 17 | global.printStackTrace = factory(); 18 | } 19 | }(this, function() { 20 | /** 21 | * Main function giving a function stack trace with a forced or passed in Error 22 | * 23 | * @cfg {Error} e The error to create a stacktrace from (optional) 24 | * @cfg {Boolean} guess If we should try to resolve the names of anonymous functions 25 | * @return {Array} of Strings with functions, lines, files, and arguments where possible 26 | */ 27 | function printStackTrace(options) { 28 | options = options || {guess: true}; 29 | var ex = options.e || null, guess = !!options.guess, mode = options.mode || null; 30 | var p = new printStackTrace.implementation(), result = p.run(ex, mode); 31 | return (guess) ? p.guessAnonymousFunctions(result) : result; 32 | } 33 | 34 | printStackTrace.implementation = function() { 35 | }; 36 | 37 | printStackTrace.implementation.prototype = { 38 | /** 39 | * @param {Error} [ex] The error to create a stacktrace from (optional) 40 | * @param {String} [mode] Forced mode (optional, mostly for unit tests) 41 | */ 42 | run: function(ex, mode) { 43 | ex = ex || this.createException(); 44 | mode = mode || this.mode(ex); 45 | if (mode === 'other') { 46 | return this.other(arguments.callee); 47 | } else { 48 | return this[mode](ex); 49 | } 50 | }, 51 | 52 | createException: function() { 53 | try { 54 | this.undef(); 55 | } catch (e) { 56 | return e; 57 | } 58 | }, 59 | 60 | /** 61 | * Mode could differ for different exception, e.g. 62 | * exceptions in Chrome may or may not have arguments or stack. 63 | * 64 | * @return {String} mode of operation for the exception 65 | */ 66 | mode: function(e) { 67 | if (typeof window !== 'undefined' && window.navigator.userAgent.indexOf('PhantomJS') > -1) { 68 | return 'phantomjs'; 69 | } 70 | 71 | if (e['arguments'] && e.stack) { 72 | return 'chrome'; 73 | } 74 | 75 | if (e.stack && e.sourceURL) { 76 | return 'safari'; 77 | } 78 | 79 | if (e.stack && e.number) { 80 | return 'ie'; 81 | } 82 | 83 | if (e.stack && e.fileName) { 84 | return 'firefox'; 85 | } 86 | 87 | if (e.message && e['opera#sourceloc']) { 88 | // e.message.indexOf("Backtrace:") > -1 -> opera9 89 | // 'opera#sourceloc' in e -> opera9, opera10a 90 | // !e.stacktrace -> opera9 91 | if (!e.stacktrace) { 92 | return 'opera9'; // use e.message 93 | } 94 | if (e.message.indexOf('\n') > -1 && e.message.split('\n').length > e.stacktrace.split('\n').length) { 95 | // e.message may have more stack entries than e.stacktrace 96 | return 'opera9'; // use e.message 97 | } 98 | return 'opera10a'; // use e.stacktrace 99 | } 100 | 101 | if (e.message && e.stack && e.stacktrace) { 102 | // e.stacktrace && e.stack -> opera10b 103 | if (e.stacktrace.indexOf("called from line") < 0) { 104 | return 'opera10b'; // use e.stacktrace, format differs from 'opera10a' 105 | } 106 | // e.stacktrace && e.stack -> opera11 107 | return 'opera11'; // use e.stacktrace, format differs from 'opera10a', 'opera10b' 108 | } 109 | 110 | if (e.stack && !e.fileName) { 111 | // Chrome 27 does not have e.arguments as earlier versions, 112 | // but still does not have e.fileName as Firefox 113 | return 'chrome'; 114 | } 115 | 116 | return 'other'; 117 | }, 118 | 119 | /** 120 | * Given a context, function name, and callback function, overwrite it so that it calls 121 | * printStackTrace() first with a callback and then runs the rest of the body. 122 | * 123 | * @param {Object} context of execution (e.g. window) 124 | * @param {String} functionName to instrument 125 | * @param {Function} callback function to call with a stack trace on invocation 126 | */ 127 | instrumentFunction: function(context, functionName, callback) { 128 | context = context || window; 129 | var original = context[functionName]; 130 | context[functionName] = function instrumented() { 131 | callback.call(this, printStackTrace().slice(4)); 132 | return context[functionName]._instrumented.apply(this, arguments); 133 | }; 134 | context[functionName]._instrumented = original; 135 | }, 136 | 137 | /** 138 | * Given a context and function name of a function that has been 139 | * instrumented, revert the function to it's original (non-instrumented) 140 | * state. 141 | * 142 | * @param {Object} context of execution (e.g. window) 143 | * @param {String} functionName to de-instrument 144 | */ 145 | deinstrumentFunction: function(context, functionName) { 146 | if (context[functionName].constructor === Function && 147 | context[functionName]._instrumented && 148 | context[functionName]._instrumented.constructor === Function) { 149 | context[functionName] = context[functionName]._instrumented; 150 | } 151 | }, 152 | 153 | /** 154 | * Given an Error object, return a formatted Array based on Chrome's stack string. 155 | * 156 | * @param e - Error object to inspect 157 | * @return Array of function calls, files and line numbers 158 | */ 159 | chrome: function(e) { 160 | return (e.stack + '\n') 161 | .replace(/^[\s\S]+?\s+at\s+/, ' at ') // remove message 162 | .replace(/^\s+(at eval )?at\s+/gm, '') // remove 'at' and indentation 163 | .replace(/^([^\(]+?)([\n$])/gm, '{anonymous}() ($1)$2') 164 | .replace(/^Object.\s*\(([^\)]+)\)/gm, '{anonymous}() ($1)') 165 | .replace(/^(.+) \((.+)\)$/gm, '$1@$2') 166 | .split('\n') 167 | .slice(0, -1); 168 | }, 169 | 170 | /** 171 | * Given an Error object, return a formatted Array based on Safari's stack string. 172 | * 173 | * @param e - Error object to inspect 174 | * @return Array of function calls, files and line numbers 175 | */ 176 | safari: function(e) { 177 | return e.stack.replace(/\[native code\]\n/m, '') 178 | .replace(/^(?=\w+Error\:).*$\n/m, '') 179 | .replace(/^@/gm, '{anonymous}()@') 180 | .split('\n'); 181 | }, 182 | 183 | /** 184 | * Given an Error object, return a formatted Array based on IE's stack string. 185 | * 186 | * @param e - Error object to inspect 187 | * @return Array of function calls, files and line numbers 188 | */ 189 | ie: function(e) { 190 | return e.stack 191 | .replace(/^\s*at\s+(.*)$/gm, '$1') 192 | .replace(/^Anonymous function\s+/gm, '{anonymous}() ') 193 | .replace(/^(.+)\s+\((.+)\)$/gm, '$1@$2') 194 | .split('\n') 195 | .slice(1); 196 | }, 197 | 198 | /** 199 | * Given an Error object, return a formatted Array based on Firefox's stack string. 200 | * 201 | * @param e - Error object to inspect 202 | * @return Array of function calls, files and line numbers 203 | */ 204 | firefox: function(e) { 205 | return e.stack.replace(/(?:\n@:0)?\s+$/m, '') 206 | .replace(/^(?:\((\S*)\))?@/gm, '{anonymous}($1)@') 207 | .split('\n'); 208 | }, 209 | 210 | opera11: function(e) { 211 | var ANON = '{anonymous}', lineRE = /^.*line (\d+), column (\d+)(?: in (.+))? in (\S+):$/; 212 | var lines = e.stacktrace.split('\n'), result = []; 213 | 214 | for (var i = 0, len = lines.length; i < len; i += 2) { 215 | var match = lineRE.exec(lines[i]); 216 | if (match) { 217 | var location = match[4] + ':' + match[1] + ':' + match[2]; 218 | var fnName = match[3] || "global code"; 219 | fnName = fnName.replace(//, "$1").replace(//, ANON); 220 | result.push(fnName + '@' + location + ' -- ' + lines[i + 1].replace(/^\s+/, '')); 221 | } 222 | } 223 | 224 | return result; 225 | }, 226 | 227 | opera10b: function(e) { 228 | // "([arguments not available])@file://localhost/G:/js/stacktrace.js:27\n" + 229 | // "printStackTrace([arguments not available])@file://localhost/G:/js/stacktrace.js:18\n" + 230 | // "@file://localhost/G:/js/test/functional/testcase1.html:15" 231 | var lineRE = /^(.*)@(.+):(\d+)$/; 232 | var lines = e.stacktrace.split('\n'), result = []; 233 | 234 | for (var i = 0, len = lines.length; i < len; i++) { 235 | var match = lineRE.exec(lines[i]); 236 | if (match) { 237 | var fnName = match[1] ? (match[1] + '()') : "global code"; 238 | result.push(fnName + '@' + match[2] + ':' + match[3]); 239 | } 240 | } 241 | 242 | return result; 243 | }, 244 | 245 | /** 246 | * Given an Error object, return a formatted Array based on Opera 10's stacktrace string. 247 | * 248 | * @param e - Error object to inspect 249 | * @return Array of function calls, files and line numbers 250 | */ 251 | opera10a: function(e) { 252 | // " Line 27 of linked script file://localhost/G:/js/stacktrace.js\n" 253 | // " Line 11 of inline#1 script in file://localhost/G:/js/test/functional/testcase1.html: In function foo\n" 254 | var ANON = '{anonymous}', lineRE = /Line (\d+).*script (?:in )?(\S+)(?:: In function (\S+))?$/i; 255 | var lines = e.stacktrace.split('\n'), result = []; 256 | 257 | for (var i = 0, len = lines.length; i < len; i += 2) { 258 | var match = lineRE.exec(lines[i]); 259 | if (match) { 260 | var fnName = match[3] || ANON; 261 | result.push(fnName + '()@' + match[2] + ':' + match[1] + ' -- ' + lines[i + 1].replace(/^\s+/, '')); 262 | } 263 | } 264 | 265 | return result; 266 | }, 267 | 268 | // Opera 7.x-9.2x only! 269 | opera9: function(e) { 270 | // " Line 43 of linked script file://localhost/G:/js/stacktrace.js\n" 271 | // " Line 7 of inline#1 script in file://localhost/G:/js/test/functional/testcase1.html\n" 272 | var ANON = '{anonymous}', lineRE = /Line (\d+).*script (?:in )?(\S+)/i; 273 | var lines = e.message.split('\n'), result = []; 274 | 275 | for (var i = 2, len = lines.length; i < len; i += 2) { 276 | var match = lineRE.exec(lines[i]); 277 | if (match) { 278 | result.push(ANON + '()@' + match[2] + ':' + match[1] + ' -- ' + lines[i + 1].replace(/^\s+/, '')); 279 | } 280 | } 281 | 282 | return result; 283 | }, 284 | 285 | phantomjs: function(e) { 286 | var ANON = '{anonymous}', lineRE = /(\S+) \((\S+)\)/i; 287 | var lines = e.stack.split('\n'), result = []; 288 | 289 | for (var i = 1, len = lines.length; i < len; i++) { 290 | lines[i] = lines[i].replace(/^\s+at\s+/gm, ''); 291 | var match = lineRE.exec(lines[i]); 292 | if (match) { 293 | result.push(match[1] + '()@' + match[2]); 294 | } 295 | else { 296 | result.push(ANON + '()@' + lines[i]); 297 | } 298 | } 299 | 300 | return result; 301 | }, 302 | 303 | // Safari 5-, IE 9-, and others 304 | other: function(curr) { 305 | var ANON = '{anonymous}', fnRE = /function(?:\s+([\w$]+))?\s*\(/, stack = [], fn, args, maxStackSize = 10; 306 | var slice = Array.prototype.slice; 307 | while (curr && stack.length < maxStackSize) { 308 | fn = fnRE.test(curr.toString()) ? RegExp.$1 || ANON : ANON; 309 | try { 310 | args = slice.call(curr['arguments'] || []); 311 | } catch (e) { 312 | args = ['Cannot access arguments: ' + e]; 313 | } 314 | stack[stack.length] = fn + '(' + this.stringifyArguments(args) + ')'; 315 | try { 316 | curr = curr.caller; 317 | } catch (e) { 318 | stack[stack.length] = 'Cannot access caller: ' + e; 319 | break; 320 | } 321 | } 322 | return stack; 323 | }, 324 | 325 | /** 326 | * Given arguments array as a String, substituting type names for non-string types. 327 | * 328 | * @param {Arguments,Array} args 329 | * @return {String} stringified arguments 330 | */ 331 | stringifyArguments: function(args) { 332 | var result = []; 333 | var slice = Array.prototype.slice; 334 | for (var i = 0; i < args.length; ++i) { 335 | var arg = args[i]; 336 | if (arg === undefined) { 337 | result[i] = 'undefined'; 338 | } else if (arg === null) { 339 | result[i] = 'null'; 340 | } else if (arg.constructor) { 341 | // TODO constructor comparison does not work for iframes 342 | if (arg.constructor === Array) { 343 | if (arg.length < 3) { 344 | result[i] = '[' + this.stringifyArguments(arg) + ']'; 345 | } else { 346 | result[i] = '[' + this.stringifyArguments(slice.call(arg, 0, 1)) + '...' + this.stringifyArguments(slice.call(arg, -1)) + ']'; 347 | } 348 | } else if (arg.constructor === Object) { 349 | result[i] = '#object'; 350 | } else if (arg.constructor === Function) { 351 | result[i] = '#function'; 352 | } else if (arg.constructor === String) { 353 | result[i] = '"' + arg + '"'; 354 | } else if (arg.constructor === Number) { 355 | result[i] = arg; 356 | } else { 357 | result[i] = '?'; 358 | } 359 | } 360 | } 361 | return result.join(','); 362 | }, 363 | 364 | sourceCache: {}, 365 | 366 | /** 367 | * @return {String} the text from a given URL 368 | */ 369 | ajax: function(url) { 370 | var req = this.createXMLHTTPObject(); 371 | if (req) { 372 | try { 373 | req.open('GET', url, false); 374 | //req.overrideMimeType('text/plain'); 375 | //req.overrideMimeType('text/javascript'); 376 | req.send(null); 377 | //return req.status == 200 ? req.responseText : ''; 378 | return req.responseText; 379 | } catch (e) { 380 | } 381 | } 382 | return ''; 383 | }, 384 | 385 | /** 386 | * Try XHR methods in order and store XHR factory. 387 | * 388 | * @return {XMLHttpRequest} XHR function or equivalent 389 | */ 390 | createXMLHTTPObject: function() { 391 | var xmlhttp, XMLHttpFactories = [ 392 | function() { 393 | return new XMLHttpRequest(); 394 | }, function() { 395 | return new ActiveXObject('Msxml2.XMLHTTP'); 396 | }, function() { 397 | return new ActiveXObject('Msxml3.XMLHTTP'); 398 | }, function() { 399 | return new ActiveXObject('Microsoft.XMLHTTP'); 400 | } 401 | ]; 402 | for (var i = 0; i < XMLHttpFactories.length; i++) { 403 | try { 404 | xmlhttp = XMLHttpFactories[i](); 405 | // Use memoization to cache the factory 406 | this.createXMLHTTPObject = XMLHttpFactories[i]; 407 | return xmlhttp; 408 | } catch (e) { 409 | } 410 | } 411 | }, 412 | 413 | /** 414 | * Given a URL, check if it is in the same domain (so we can get the source 415 | * via Ajax). 416 | * 417 | * @param url {String} source url 418 | * @return {Boolean} False if we need a cross-domain request 419 | */ 420 | isSameDomain: function(url) { 421 | return typeof location !== "undefined" && url.indexOf(location.hostname) !== -1; // location may not be defined, e.g. when running from nodejs. 422 | }, 423 | 424 | /** 425 | * Get source code from given URL if in the same domain. 426 | * 427 | * @param url {String} JS source URL 428 | * @return {Array} Array of source code lines 429 | */ 430 | getSource: function(url) { 431 | // TODO reuse source from script tags? 432 | if (!(url in this.sourceCache)) { 433 | this.sourceCache[url] = this.ajax(url).split('\n'); 434 | } 435 | return this.sourceCache[url]; 436 | }, 437 | 438 | guessAnonymousFunctions: function(stack) { 439 | for (var i = 0; i < stack.length; ++i) { 440 | var reStack = /\{anonymous\}\(.*\)@(.*)/, 441 | reRef = /^(.*?)(?::(\d+))(?::(\d+))?(?: -- .+)?$/, 442 | frame = stack[i], ref = reStack.exec(frame); 443 | 444 | if (ref) { 445 | var m = reRef.exec(ref[1]); 446 | if (m) { // If falsey, we did not get any file/line information 447 | var file = m[1], lineno = m[2], charno = m[3] || 0; 448 | if (file && this.isSameDomain(file) && lineno) { 449 | var functionName = this.guessAnonymousFunction(file, lineno, charno); 450 | stack[i] = frame.replace('{anonymous}', functionName); 451 | } 452 | } 453 | } 454 | } 455 | return stack; 456 | }, 457 | 458 | guessAnonymousFunction: function(url, lineNo, charNo) { 459 | var ret; 460 | try { 461 | ret = this.findFunctionName(this.getSource(url), lineNo); 462 | } catch (e) { 463 | ret = 'getSource failed with url: ' + url + ', exception: ' + e.toString(); 464 | } 465 | return ret; 466 | }, 467 | 468 | findFunctionName: function(source, lineNo) { 469 | // FIXME findFunctionName fails for compressed source 470 | // (more than one function on the same line) 471 | // function {name}({args}) m[1]=name m[2]=args 472 | var reFunctionDeclaration = /function\s+([^(]*?)\s*\(([^)]*)\)/; 473 | // {name} = function ({args}) TODO args capture 474 | // /['"]?([0-9A-Za-z_]+)['"]?\s*[:=]\s*function(?:[^(]*)/ 475 | var reFunctionExpression = /['"]?([$_A-Za-z][$_A-Za-z0-9]*)['"]?\s*[:=]\s*function\b/; 476 | // {name} = eval() 477 | var reFunctionEvaluation = /['"]?([$_A-Za-z][$_A-Za-z0-9]*)['"]?\s*[:=]\s*(?:eval|new Function)\b/; 478 | // Walk backwards in the source lines until we find 479 | // the line which matches one of the patterns above 480 | var code = "", line, maxLines = Math.min(lineNo, 20), m, commentPos; 481 | for (var i = 0; i < maxLines; ++i) { 482 | // lineNo is 1-based, source[] is 0-based 483 | line = source[lineNo - i - 1]; 484 | commentPos = line.indexOf('//'); 485 | if (commentPos >= 0) { 486 | line = line.substr(0, commentPos); 487 | } 488 | // TODO check other types of comments? Commented code may lead to false positive 489 | if (line) { 490 | code = line + code; 491 | m = reFunctionExpression.exec(code); 492 | if (m && m[1]) { 493 | return m[1]; 494 | } 495 | m = reFunctionDeclaration.exec(code); 496 | if (m && m[1]) { 497 | //return m[1] + "(" + (m[2] || "") + ")"; 498 | return m[1]; 499 | } 500 | m = reFunctionEvaluation.exec(code); 501 | if (m && m[1]) { 502 | return m[1]; 503 | } 504 | } 505 | } 506 | return '(?)'; 507 | } 508 | }; 509 | 510 | return printStackTrace; 511 | })); 512 | --------------------------------------------------------------------------------