# # AJAXifies form including file uploads and stuff. Built on top of jQuery.Form. # # Joosy.Form automatically cares of form validation highlights. It can # read common server error responses and add .field_with_errors class to proper # field. # # If you don't have resource associated with form (#fill), it will try to find fields # by exact keywords from response. Otherwise it will search for resource_name[field]. # # @example Joosy.Form usage # form = new Joosy.Form, -> (response) # console.log "Saved and got some: #{response}" # # form.progress = (percent) -> console.log "Uploaded by #{percent}%" # form.fill @resource # # @include Joosy.Modules.Log # @include Joosy.Modules.Events # @concern Joosy.Modules.DOM # class Joosy.Form extends Joosy.Module @concern Joosy.Modules.DOM @include Joosy.Modules.Log @include Joosy.Modules.Events # # Marks the CSS class to use to mark invalidated fields # invalidationClass: 'field_with_errors' # # List of mappings for fields of invalidated data which comes from server # # If you have something like {foo: 'bar', bar: 'baz'} coming from server # substitutions = {foo: 'foo_id'} will change it to {foo_id: 'bar', bar: 'baz'} # substitutions: {} # # List of elements for internal usage # @mapElements 'fields': 'input,select,textarea' # # Makes one AJAX request with form data without binding # # @see #constructor # # @param [Element] form Instance of HTML form element # @param [Hash] options Options # @submit: (form, options={}) -> form = new @(form, options) form.$container.submit() form.unbind() null @attach: -> new Joosy.Form arguments... # # During initialization replaces your basic form submit with AJAX request # # @note If method of form differs from POST or GET it will simulate it # by adding hidden _method input. In this cases the method itself will be # set to POST. # # @note For browsers having no support of HTML5 Forms it may do an iframe requests # to handle file uploading. # # @param [jQuery] form Form element instance # @param [Hash] options Options # # @option options [Function] before `(XHR) -> Bollean` to trigger right before submit. # By default will run form invalidation cleanup. This behavior can be canceled # by returning false from your own before callback. Both of callbacks will run if # you return true. # # @option options [Function] success `(Object) -> null` triggers on 2xx HTTP code from server. # Pases in the parsed JSON. # # @option options [Function] progress `(Float) -> null` runs periodically while form is uploading # # @option options [Function] error `(Object) -> Boolean` triggers if server responded with anything but 2xx. # By default will run form invalidation routine. This behavior can be canceled # by returning false from your own error callback. Both of callbacks will run if # you return true. # # @option options [Joosy.Resources.Base] resource The resource to fill the form with # @option options [String] resourceName The string to use as a resource name prefix for fields to match invalidation # @option options [String] action Action URL for the form # @option options [String] method HTTP method, for example PUT, that will passed in _method param # @option options [Boolean] debounce Drop submit events while there is a pending submit request # constructor: (form, options={}) -> if typeof(options) == 'function' @success = options else @[key] = value for key, value of options @$container = $(form) return if @$container.length == 0 @__assignElements() @__delegateEvents() method = @$container.get(0).getAttribute('method')?.toLowerCase() if method && ['get', 'post'].indexOf(method) == -1 @__markMethod method @$container.attr 'method', 'POST' @$container.ajaxForm dataType: 'json' beforeSend: => return false if @__debounce arguments... @__before arguments... @__pending_request = true @debugAs this, 'beforeSend: pending_request = true' true success: => @__pending_request = false @debugAs this, 'success: pending_request = false' @__success arguments... error: => @__pending_request = false @debugAs this, 'error: pending_request = false' @__error arguments... xhr: => xhr = $.ajaxSettings.xhr() if xhr.upload? && @progress xhr.upload.onprogress = (event) => if event.lengthComputable @progress (event.position / event.total * 100).round 2 xhr if @resource? @fill(@resource, options) delete @resource if @action? @$container.attr 'action', @action @$container.attr 'method', 'POST' if @method? @__markMethod @method # # Resets form submit behavior to default # unbind: -> @$container.unbind('submit').find('input:submit,input:image,button:submit').unbind('click') # # Links current form with given resource and sets values of form inputs from with it. # Form will use given resource while doing invalidation routine. # # @param [Resource] resource Resource to fill fields with # @param [Hash] options Options # # @option options [Function] decorator `(Object) -> Object` decoration callback # Pases in the parsed JSON. # # @option options [String] action Action URL for the form # fill: (resource, options) -> resource = resource.build() if typeof(resource.build) == 'function' @__resource = resource if options?.decorator? data = options.decorator resource.data else data = resource.data filler = (data, scope) => return if data.__joosy_form_filler_lock data.__joosy_form_filler_lock = true for property, val of data do (property, val) => key = @concatFieldName scope, property input = @$fields().filter("[name='#{key}']:not(:file),[name='#{inflection.underscore(key)}']:not(:file),[name='#{inflection.camelize(key, true)}']:not(:file)") if input.length > 0 if input.is ':checkbox' input.prop 'checked', !!val else if input.is ':radio' input.filter("[value='#{val}']").prop 'checked', true else input.val val if val instanceof Joosy.Resources.Array for entity, i in val filler entity.data, @concatFieldName(scope, "[#{property}_attributes][#{i}]") else if val instanceof Joosy.Resources.REST filler val.data, @concatFieldName(scope, "[#{property}_attributes]") else if val?.constructor == Object || val instanceof Array filler val, key else delete data.__joosy_form_filler_lock filler data, resource.__entityName || options.resourceName $('input[name=_method]', @$container).remove() @__markMethod(options?.method || 'PUT') if resource.id() url = options?.action || (if resource.id()? then resource.memberPath() else resource.collectionPath()) @$container.attr 'action', url @$container.attr 'method', 'POST' # # Submit the HTML Form # submit: -> @$container.submit() # # Serializes form into query string. # # @param [Boolean] skipMethod Determines if we should skip magical _method field # # @return [String] # serialize: (skipMethod=true) -> data = @$container.serialize() data = data.replace /\&?\_method\=put/i, '' if skipMethod data # # Inner success callback. # __success: (response, status, xhr) -> if xhr @success? response else if 200 <= response.status < 300 @success response.json else @__error response.json # # Inner before callback. # By default will clean invalidation. # __before: (xhr, settings) -> if !@before? || @before(arguments...) is true @$fields().removeClass @invalidationClass # # Inner error callback. # By default will trigger basic invalidation. # __error: (data) -> errors = if data.responseText try data = jQuery.parseJSON(data.responseText) catch error {} else data if !@error? || @error(errors) is true errors = @__stringifyErrors(errors) for field, notifications of errors do (field, notifications) => input = @findField(field).addClass @invalidationClass @notification? input, notifications return errors return false # # Aborts form submit if there is already another one pending XHR # __debounce: (xhr) -> @debugAs this, "debounce: pending_request == #{@__pending_request}" if @__pending_request && @debounce != false if @debounce || Joosy.Form.debounceForms xhr.abort() @debugAs this, "debounce: xhr aborted" return true false # # Finds field by field name. # This is not inlined since we want to override # or monkeypatch it from time to time # # @param [String] field Name of field to find # findField: (field) -> @$fields().filter("[name='#{field}']") # # Simulates REST methods by adding hidden _method input with real method # while setting POST as the transport method. # # @param [String] method Real method to simulate # __markMethod: (method='PUT') -> method = $('', type: 'hidden' name: '_method' value: method ) @$container.append method # # Prepares server response for default error handler # Turns all possible response notations into form notation (foo[bar]) # Every direct field of incoming data will be decorated by @substitutions # # @example Flat validation result # { field1: ['error'] } # input # { field1: ['error'] } # if form was not associated with Resource by {#fill} # { "fluffy[field1]": ['error']} # if form was associated with Resource (named fluffy) # # @example Complex validation result # { foo: { bar: { baz: ['error'] } } } # input # { "foo[bar][bar]": ['error'] } # output # # @param [Object] errors Data to prepare # # @return [Hash] Flat hash with field names in keys and arrays # of errors in values # __stringifyErrors: (errors) -> result = {} errors = errors.errors if errors?.errors?.constructor == Object for field, notifications of errors do (field, notifications) => if @substitutions[field]? field = @substitutions[field] if notifications.constructor == Object || @isArrayOfObjects(notifications) result[field+key] = value for key, value of @__foldInlineEntities(notifications) else if field.indexOf(".") != -1 splited = field.split '.' field = splited.shift() if @resourceName || @__resource name = @resourceName || @__resource.__entityName field = name + "[#{field}]" field += "[#{f}]" for f in splited else if @resourceName || @__resource name = @resourceName || @__resource.__entityName field = name + "[#{field}]" result[field] = notifications result # # Flattens complex inline structures into form notation # # @example Basic flattening # data = foo: { bar: { baz: [] } } # inner = @__foldInlineEntities(data.foo, 'foo') # # inner # { "foo[bar][baz]": [] } # # @param [Object] hash Structure to fold # @param [String] scope Prefix for resulting scopes # @param [Object] result Context of result for recursion # # @return [Hash] # __foldInlineEntities: (hash, scope="", result={}) -> for key, value of hash if value?.constructor == Object || @isArrayOfObjects(value) @__foldInlineEntities(value, "#{scope}[#{key}]", result) else result["#{scope}[#{key}]"] = value result concatFieldName: (wrapper, name) -> items = @splitFieldName(wrapper).concat @splitFieldName(name) "#{items[0]}[#{items.slice(1).join('][')}]" splitFieldName: (name) -> items = name.split('][') first = items[0].split('[') if first.length == 2 if first[0].length == 0 items.splice 0, 1, first[1] else items.splice 0, 1, first[0], first[1] items[items.length - 1] = items[items.length - 1].split(']')[0] items isArrayOfObjects: (array) -> array instanceof Array && array.filter((elem) -> elem?.constructor != Object).length == 0 # AMD wrapper if define?.amd? define 'joosy/form', -> Joosy.Form