diff options
author | David Heinemeier Hansson <david@loudthinking.com> | 2005-06-28 17:44:16 +0000 |
---|---|---|
committer | David Heinemeier Hansson <david@loudthinking.com> | 2005-06-28 17:44:16 +0000 |
commit | beffb77e05bf3c192e798587812105458146df32 (patch) | |
tree | e51ff71df80264ad08447719d842929ef1e9fd8e /actionpack | |
parent | 62ed6950c9119c12e6b17a68c79c45c77a7b1ca4 (diff) | |
download | rails-beffb77e05bf3c192e798587812105458146df32.tar.gz rails-beffb77e05bf3c192e798587812105458146df32.tar.bz2 rails-beffb77e05bf3c192e798587812105458146df32.zip |
Added support for upload progress indicators in Apache and lighttpd 1.4.x (won't work in WEBrick or lighttpd 1.3.x) #1475 [Sean Treadway]
git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@1553 5ecf4fe2-1ee6-0310-87b1-e25e094e27de
Diffstat (limited to 'actionpack')
6 files changed, 1730 insertions, 0 deletions
diff --git a/actionpack/lib/action_controller/cgi_ext/multipart_progress.rb b/actionpack/lib/action_controller/cgi_ext/multipart_progress.rb new file mode 100644 index 0000000000..d297c6f86e --- /dev/null +++ b/actionpack/lib/action_controller/cgi_ext/multipart_progress.rb @@ -0,0 +1,167 @@ +# == Overview +# +# This module will extend the CGI module with methods to track the upload +# progress for multipart forms for use with progress meters. The progress is +# saved in the session to be used from any request from any server with the +# same session. In other words, this module will work across application +# instances. +# +# === Usage +# +# Just do your file-uploads as you normally would, but include an upload_id in +# the query string of your form action. Your form post action should look +# like: +# +# <form method="post" enctype="multipart/form-data" action="postaction?upload_id=SOMEIDYOUSET"> +# <input type="file" name="client_file"/> +# </form> +# +# Query the upload state in a progress by reading the progress from the session +# +# class UploadController < ApplicationController +# def upload_status +# render :text => "Percent complete: " + @session[:uploads]['SOMEIDYOUSET'].completed_percent" +# end +# end +# +# === Session options +# +# Upload progress uses the session options defined in +# ActionController::CgiRequest::DEFAULT_SESSION_OPTIONS. If you are passing +# custom session options to your dispatcher then please follow the +# "recommended way to change session options":http://wiki.rubyonrails.com/rails/show/HowtoChangeSessionOptions +# +# === Update frequency +# +# During an upload, the progress will be written to the session every 2 +# seconds. This prevents excessive writes yet maintains a decent picture of +# the upload progress for larger files. +# +# User interfaces that update more often that every 2 seconds will display the same results. +# Consider this update frequency when designing your progress polling. +# + +require 'cgi' + +# For integration with ActionPack +require 'action_controller/base' +require 'action_controller/cgi_process' +require 'action_controller/upload_progress' + +class CGI #:nodoc: + class ProgressIO < SimpleDelegator #:nodoc: + MIN_SAVE_INTERVAL = 1.0 # Number of seconds between session saves + + attr_reader :progress, :session + + def initialize(orig_io, progress, session) + @session = session + @progress = progress + + @start_time = Time.now + @last_save_time = @start_time + save_progress + + super(orig_io) + end + + def read(*args) + data = __getobj__.read(*args) + + if data and data.size > 0 + now = Time.now + elapsed = now - @start_time + progress.update!(data.size, elapsed) + + if now - @last_save_time > MIN_SAVE_INTERVAL + save_progress + @last_save_time = now + end + else + ActionController::Base.logger.debug("CGI::ProgressIO#read returns nothing when it should return nil if IO is finished: [#{args.inspect}], a cancelled upload or old FCGI bindings. Resetting the upload progress") + + progress.reset! + save_progress + end + + data + end + + def save_progress + @session.update + end + + def finish + @session.update + ActionController::Base.logger.debug("Finished processing multipart upload in #{@progress.elapsed_seconds.to_s}s") + end + end + + module QueryExtension #:nodoc: + # Need to do lazy aliasing on the instance that we are extending because of the way QueryExtension + # gets included for each instance of the CGI object rather than on a module level. This method is a + # bit obtrusive because we are overriding CGI::QueryExtension::extended which could be used in the + # future. Need to research a better method + def self.extended(obj) + obj.instance_eval do + # unless defined? will prevent clobbering the progress IO on multiple extensions + alias :stdinput_without_progress :stdinput unless defined? stdinput_without_progress + alias :stdinput :stdinput_with_progress + end + end + + def stdinput_with_progress + @stdin_with_progress or stdinput_without_progress + end + + private + # Bootstrapped on ActionController::UploadProgress::upload_status_for + def read_multipart_with_progress(boundary, content_length) + begin + begin + # Session disabled if the default session options have been set to 'false' + options = ActionController::CgiRequest::DEFAULT_SESSION_OPTIONS + raise RuntimeError.new("Multipart upload progress disabled, no session options") unless options + + options = options.stringify_keys + + #Controllers.const_load!(:ApplicationController, "application") unless Controllers.const_defined?(:ApplicationController) + + # Assumes that @cookies has already been setup + # Raises nomethod if upload_id is not defined + @params = CGI::parse(read_params_from_query) + upload_id = @params[(options['upload_key'] || 'upload_id')].first + raise RuntimeError.new("Multipart upload progress disabled, no upload id in query string") unless upload_id + + upload_progress = ActionController::UploadProgress::Progress.new(content_length) + + session = Session.new(self, options) + session[:uploads] = {} unless session[:uploads] + session[:uploads].delete(upload_id) # in case the same upload id is used twice + session[:uploads][upload_id] = upload_progress + + @stdin_with_progress = CGI::ProgressIO.new(stdinput_without_progress, upload_progress, session) + ActionController::Base.logger.debug("Multipart upload with progress (id: #{upload_id}, size: #{content_length})") + rescue + ActionController::Base.logger.debug("Exception during setup of read_multipart_with_progress: #{$!}") + end + ensure + begin + params = read_multipart_without_progress(boundary, content_length) + @stdin_with_progress.finish if @stdin_with_progress.respond_to? :finish + ensure + @stdin_with_progress = nil + session.close if session + end + end + params + end + + # Prevent redefinition of aliases on multiple includes + unless private_instance_methods.include?("read_multipart_without_progress") + alias_method :read_multipart_without_progress, :read_multipart + alias_method :read_multipart, :read_multipart_with_progress + end + + end +end diff --git a/actionpack/lib/action_controller/upload_progress.rb b/actionpack/lib/action_controller/upload_progress.rb new file mode 100644 index 0000000000..ce8d51c67d --- /dev/null +++ b/actionpack/lib/action_controller/upload_progress.rb @@ -0,0 +1,410 @@ +# Unfortunately we need to require multipart_progress here and not in +# uplaod_status_for because if the upload happens to hit a fresh FCGI instance +# the upload_status_for method will be called after the CGI object is created +# Requiring here means that multipart progress will be enabled for all multipart +# postings. +require 'action_controller/cgi_ext/multipart_progress' + +module ActionController #:nodoc: + # == Action Pack Upload Progress for multipart uploads + # + # The UploadProgress module aids in the process of viewing an Ajax driven + # upload status when working with multipart forms. It offers a macro that + # will prepare an action for handling the cleanup of the Ajax updating including + # passing the redirect URL and custom parameters to the Javascript finish handler. + # + # UploadProgress is available for all multipart uploads when the +upload_status_for+ + # macro is called in one of your controllers. + # + # The progress is stored as an UploadProgress::Progress object in the session and + # is accessible in the controller and view with the +upload_progress+ method. + # + # For help rendering the UploadProgress enabled form and supported elements, see + # ActionView::Helpers::UploadProgressHelper. + # + # === Automatic updating on upload actions + # + # class DocumentController < ApplicationController + # upload_status_for :create + # + # def create + # # ... Your document creation action + # end + # end + # + # The +upload_status_for+ macro will override the rendering of the action passed + # if +upload_id+ is found in the query string. This allows for default + # behavior if Javascript is disabled. If you are tracking the upload progress + # then +create+ will now return the cleanup scripts that will terminate the polling + # of the upload status. + # + # === Customized status rendering + # + # class DocumentController < ApplicationController + # upload_status_for :create, :status => :custom_status + # + # def create + # # ... Your document creation action + # end + # + # def custom_status + # # ... Override this action to return content to be replaced in + # # the status container + # render :inline => "<%= upload_progress.completed_percent rescue 0 %> % complete", :layout => false + # end + # + # The default status action is +upload_status+. The results of this action + # are added used to replace the contents of the HTML elements defined in + # +upload_status_tag+. Within +upload_status+, you can load the Progress + # object from the session with the +upload_progress+ method and display your own + # results. + # + # Completion of the upload status updating occurs automatically with an +after_filter+ call to + # +finish_upload_status+. Because the upload must be posted into a hidden IFRAME to enable + # Ajax updates during the upload, +finish_upload_status+ overwrites the results of any previous + # +render+ or +redirect_to+ so it can render the necessary Javascript that will properly terminate + # the status updating loop, trigger the completion callback or redirect to the appropriate URL. + # + # ==== Basic Example (View): + # + # <%= form_tag_with_upload_progress({:action => 'create'}, {:finish => 'alert("Document Uploaded")'}) %> + # <%= upload_status_tag %> + # <%= file_field 'document', 'file' %> + # <%= end_form_tag %> + # + # ==== Basic Example (Controller): + # + # class DocumentController < ApplicationController + # upload_status_for :create + # + # def create + # @document = Document.create(params[:document]) + # end + # end + # + # ==== Extended Example (View): + # + # <%= form_tag_with_upload_progress({:action => 'create'}, {}, {:action => :custom_status}) %> + # <%= upload_status_tag %> + # <%= file_field 'document', 'file' %> + # <%= submit_tag "Upload" %> + # <%= end_form_tag %> + # + # <%= form_tag_with_upload_progress({:action => 'add_preview'}, {:finish => 'alert(arguments[0])'}, {:action => :custom_status}) %> + # <%= upload_status_tag %> + # <%= submit_tag "Upload" %> + # <%= file_field 'preview', 'file' %> + # <%= end_form_tag %> + # + # ==== Extended Example (Controller): + # + # class DocumentController < ApplicationController + # upload_status_for :add_preview, :create, {:status => :custom_status} + # + # def add_preview + # @document = Document.find(params[:id]) + # @document.preview = Preview.create(params[:preview]) + # if @document.save + # finish_upload_status "'Preview added'" + # else + # finish_upload_status "'Preview not added'" + # end + # end + # + # def create + # @document = Document.new(params[:document]) + # + # upload_progress.message = "Processing document..." + # session.update + # + # @document.save + # redirect_to :action => 'show', :id => @document.id + # end + # + # def custom_status + # render :inline => '<%= upload_progress_status %> <div>Updated at <%= Time.now %></div>', :layout => false + # end + # + # + module UploadProgress + + def self.append_features(base) #:nodoc: + super + base.extend(ClassMethods) + base.helper_method :upload_progress, :next_upload_id, :last_upload_id, :current_upload_id + end + + module ClassMethods + # Creates an +after_filter+ which will call +finish_upload_status+ + # creating the document that will be loaded into the hidden IFRAME, terminating + # the status polling forms created with +form_with_upload_progress+. + # + # Also defines an action +upload_status+ or a action name passed as + # the <tt>:status</tt> option. This status action must match the one expected + # in the +form_tag_with_upload_progress+ helper. + # + def upload_status_for(*actions) + after_filter :finish_upload_status, :only => actions + + define_method(actions.last.is_a?(Hash) && actions.last[:status] || :upload_status) do + render(:inline => '<%= upload_progress_status %>', :layout => false) + end + end + end + + # Overwrites the body rendered if the upload comes from a form that tracks + # the progress of the upload. After clearing the body and any redirects, this + # method then renders the helper +finish_upload_status+ + # + # This method only needs to be called if you wish to pass a + # javascript parameter to your finish event handler that you optionally + # define in +form_with_upload_progress+ + # + # === Parameter: + # + # client_js_argument:: a string containing a Javascript expression that will + # be evaluated and passed to your +finish+ handler of + # +form_tag_with_upload_progress+. + # + # You can pass a String, Number or Boolean. + # + # === Strings + # + # Strings contain Javascript code that will be evaluated on the client. If you + # wish to pass a string to the client finish callback, you will need to include + # quotes in the +client_js_argument+ you pass to this method. + # + # ==== Example + # + # finish_upload_status("\"Finished\"") + # finish_upload_status("'Finished #{@document.title}'") + # finish_upload_status("{success: true, message: 'Done!'}") + # finish_upload_status("function() { alert('Uploaded!'); }") + # + # === Numbers / Booleans + # + # Numbers and Booleans can either be passed as Number objects or string versions + # of number objects as they are evaluated by Javascript the same way as in Ruby. + # + # ==== Example + # + # finish_upload_status(0) + # finish_upload_status(@document.file.size) + # finish_upload_status("10") + # + # === Nil + # + # To pass +nil+ to the finish callback, use a string "undefined" + # + # ==== Example + # + # finish_upload_status(@message || "undefined") + # + # == Redirection + # + # If you action performs a redirection then +finish_upload_status+ will recognize + # the redirection and properly create the Javascript to perform the redirection in + # the proper location. + # + # It is possible to redirect and pass a parameter to the finish callback. + # + # ==== Example + # + # redirect_to :action => 'show', :id => @document.id + # finish_upload_status("'Redirecting you to your new file'") + # + # + def finish_upload_status(client_js_argument='') + if not @rendered_finish_upload_status and params[:upload_id] + @rendered_finish_upload_status = true + + erase_render_results + location = erase_redirect_results || '' + + ## TODO determine if #inspect is the appropriate way to marshall values + ## in inline templates + + template = "<%= finish_upload_status({" + template << ":client_js_argument => #{client_js_argument.inspect}, " + template << ":redirect_to => #{location.to_s.inspect}, " + template << "}) %>" + + render({ :inline => template, :layout => false }) + end + end + + # Returns and saves the next unique +upload_id+ in the instance variable + # <tt>@upload_id</tt> + def next_upload_id + @upload_id = last_upload_id.succ + end + + # Either returns the last saved +upload_id+ or looks in the session + # for the last used +upload_id+ and saves it as the intance variable + # <tt>@upload_id</tt> + def last_upload_id + @upload_id ||= ((session[:uploads] || {}).keys.map{|k| k.to_i}.sort.last || 0).to_s + end + + # Returns the +upload_id+ from the query parameters or if it cannot be found + # in the query parameters, then return the +last_upload_id+ + def current_upload_id + params[:upload_id] or last_upload_id + end + + # Get the UploadProgress::Progress object for the supplied +upload_id+ from the + # session. If no +upload_id+ is given, then use the +current_upload_id+ + # + # If an UploadProgress::Progress object cannot be found, a new instance will be + # returned with <code>total_bytes == 0</code>, <code>started? == false</code>, + # and <code>finished? == true</code>. + def upload_progress(upload_id = nil) + upload_id ||= current_upload_id + session[:uploads] && session[:uploads][upload_id] || UploadProgress::Progress.new(0) + end + + # Upload Progress abstracts the progress of an upload. It's used by the + # multipart progress IO that keeps track of the upload progress and creating + # the application depends on. It contians methods to update the progress + # during an upload and read the statistics such as +received_bytes+, + # +total_bytes+, +completed_percent+, +bitrate+, and + # +remaining_seconds+ + # + # You can get the current +Progress+ object by calling +upload_progress+ instance + # method in your controller or view. + # + class Progress + unless const_defined? :MIN_SAMPLE_TIME + # Number of seconds between bitrate samples. Updates that occur more + # frequently than +MIN_SAMPLE_TIME+ will not be queued until this + # time passes. This behavior gives a good balance of accuracy and load + # for both fast and slow transfers. + MIN_SAMPLE_TIME = 0.150 + + # Number of seconds between updates before giving up to try and calculate + # bitrate anymore + MIN_STALL_TIME = 10.0 + + # Number of samples used to calculate bitrate + MAX_SAMPLES = 20 + end + + # Number bytes received from the multipart post + attr_reader :received_bytes + + # Total number of bytes expected from the mutlipart post + attr_reader :total_bytes + + # The last time the upload history was updated + attr_reader :last_update_time + + # A message you can set from your controller or view to be rendered in the + # +upload_status_text+ helper method. If you set a messagein a controller + # then call <code>session.update</code> to make that message available to + # your +upload_status+ action. + attr_accessor :message + + # Create a new Progress object passing the expected number of bytes to receive + def initialize(total) + @total_bytes = total + reset! + end + + # Resets the received_bytes, last_update_time, message and bitrate, but + # but maintains the total expected bytes + def reset! + @received_bytes, @last_update_time, @stalled, @message = 0, 0, false, '' + reset_history + end + + # Number of bytes left for this upload + def remaining_bytes + @total_bytes - @received_bytes + end + + # Completed percent in integer form from 0..100 + def completed_percent + (@received_bytes * 100 / @total_bytes).to_i rescue 0 + end + + # Updates this UploadProgress object with the number of bytes received + # since last update time and the absolute number of seconds since the + # beginning of the upload. + # + # This method is used by the +MultipartProgress+ module and should + # not be called directly. + def update!(bytes, elapsed_seconds)#:nodoc: + if @received_bytes + bytes > @total_bytes + #warn "Progress#update received bytes exceeds expected bytes" + bytes = @total_bytes - @received_bytes + end + + @received_bytes += bytes + + # Age is the duration of time since the last update to the history + age = elapsed_seconds - @last_update_time + + # Record the bytes received in the first element of the history + # in case the sample rate is exceeded and we shouldn't record at this + # time + @history.first[0] += bytes + @history.first[1] += age + + history_age = @history.first[1] + + @history.pop while @history.size > MAX_SAMPLES + @history.unshift([0,0]) if history_age > MIN_SAMPLE_TIME + + if history_age > MIN_STALL_TIME + @stalled = true + reset_history + else + @stalled = false + end + + @last_update_time = elapsed_seconds + + self + end + + # Calculates the bitrate in bytes/second. If the transfer is stalled or + # just started, the bitrate will be 0 + def bitrate + history_bytes, history_time = @history.transpose.map { |vals| vals.inject { |sum, v| sum + v } } + history_bytes / history_time rescue 0 + end + + # Number of seconds elapsed since the start of the upload + def elapsed_seconds + @last_update_time + end + + # Calculate the seconds remaining based on the current bitrate. Returns + # O seconds if stalled or if no bytes have been received + def remaining_seconds + remaining_bytes / bitrate rescue 0 + end + + # Returns true if there are bytes pending otherwise returns false + def finished? + remaining_bytes <= 0 + end + + # Returns true if some bytes have been received + def started? + @received_bytes > 0 + end + + # Returns true if there has been a delay in receiving bytes. The delay + # is set by the constant MIN_STALL_TIME + def stalled? + @stalled + end + + private + def reset_history + @history = [[0,0]] + end + end + end +end diff --git a/actionpack/lib/action_view/helpers/upload_progress_helper.rb b/actionpack/lib/action_view/helpers/upload_progress_helper.rb new file mode 100644 index 0000000000..1af8bce754 --- /dev/null +++ b/actionpack/lib/action_view/helpers/upload_progress_helper.rb @@ -0,0 +1,427 @@ +module ActionView + module Helpers + # Provides a set of methods to be used in your views to help with the + # rendering of Ajax enabled status updating during a multipart upload. + # + # The basic mechanism for upload progress is that the form will post to a + # hidden <iframe> element, then poll a status action that will replace the + # contents of a status container. Client Javascript hooks are provided for + # +begin+ and +finish+ of the update. + # + # If you wish to have a DTD that will validate this page, use XHTML + # Transitional because this DTD supports the <iframe> element. + # + # == Typical usage + # + # In your upload view: + # + # <%= form_tag_with_upload_progress({ :action => 'create' }) %> + # <%= file_field "document", "file" %> + # <%= submit_tag "Upload" %> + # <%= upload_status_tag %> + # <%= end_form_tag %> + # + # In your controller: + # + # class DocumentController < ApplicationController + # upload_status_for :create + # + # def create + # # ... Your document creation action + # end + # end + # + # == Javascript callback on begin and finished + # + # In your upload view: + # + # <%= form_tag_with_upload_progress({ :action => 'create' }, { + # :begin => "alert('upload beginning'), + # :finish => "alert('upload finished')}) %> + # <%= file_field "document", "file" %> + # <%= submit_tag "Upload" %> + # <%= upload_status_tag %> + # <%= end_form_tag %> + # + # + # == CSS Styling of the status text and progress bar + # + # See +upload_status_text_tag+ and +upload_status_progress_bar_tag+ for references + # of the IDs and CSS classes used. + # + # Default styling is included with the scaffolding CSS. + # + module UploadProgressHelper + unless const_defined? :FREQUENCY + # Default number of seconds between client updates + FREQUENCY = 2.0 + + # Factor to decrease the frequency when the +upload_status+ action returns the same results + # To disable update decay, set this constant to +false+ + FREQUENCY_DECAY = 1.8 + end + + # Contains a hash of status messages used for localization of + # +upload_progress_status+ and +upload_progress_text+. Each string is + # evaluated in the helper method context so you can include your own + # calculations and string iterpolations. + # + # The following keys are defined: + # + # <tt>:begin</tt>:: Displayed before the first byte is received on the server + # <tt>:update</tt>:: Contains a human representation of the upload progress + # <tt>:finish</tt>:: Displayed when the file upload is complete, before the action has completed. If you are performing extra activity in your action such as processing of the upload, then inform the user of what you are doing by setting +upload_progress.message+ + # + @@default_messages = { + :begin => '"Upload starting..."', + :update => '"#{human_size(upload_progress.received_bytes)} of #{human_size(upload_progress.total_bytes)} at #{human_size(upload_progress.bitrate)}/s; #{distance_of_time_in_words(0,upload_progress.remaining_seconds,true)} remaining"', + :finish => 'upload_progress.message.blank? ? "Upload finished." : upload_progress.message', + } + + + # Creates a form tag and hidden <iframe> necessary for the upload progress + # status messages to be displayed in a designated +div+ on your page. + # + # == Initializations + # + # When the upload starts, the content created by +upload_status_tag+ will be filled out with + # "Upload starting...". When the upload is finished, "Upload finished." will be used. Every + # update inbetween the begin and finish events will be determined by the server +upload_status+ + # action. Doing this automatically means that the user can use the same form to upload multiple + # files without refreshing page while still displaying a reasonable progress. + # + # == Upload IDs + # + # For the view and the controller to know about the same upload they must share + # a common +upload_id+. +form_tag_with_upload_progress+ prepares the next available + # +upload_id+ when called. There are other methods which use the +upload_id+ so the + # order in which you include your content is important. Any content that depends on the + # +upload_id+ will use the one defined +form_tag_with_upload_progress+ + # otherwise you will need to explicitly declare the +upload_id+ shared among + # your progress elements. + # + # Status container after the form: + # + # <%= form_tag_with_upload_progress %> + # <%= end_form_tag %> + # + # <%= upload_status_tag %> + # + # Status container before form: + # + # <% my_upload_id = next_upload_id %> + # + # <%= upload_status_tag %> + # + # <%= form_tag_with_upload_progress :upload_id => my_upload_id %> + # <%= end_form_tag %> + # + # It is recommended that the helpers manage the +upload_id+ parameter. + # + # == Options + # + # +form_tag_with_upload_progress+ uses similar options as +form_tag+ + # yet accepts another hash for the options used for the +upload_status+ action. + # + # <tt>url_for_options</tt>:: The same options used by +form_tag+ including: + # <tt>:upload_id</tt>:: the upload id used to uniquely identify this upload + # + # <tt>options</tt>:: similar options to +form_tag+ including: + # <tt>:begin</tt>:: Javascript code that executes before the first status update occurs. + # <tt>:finish</tt>:: Javascript code that executes after the action that receives the post returns. + # <tt>:frequency</tt>:: number of seconds between polls to the upload status action. + # + # <tt>status_url_for_options</tt>:: options passed to +url_for+ to build the url + # for the upload status action. + # <tt>:controller</tt>:: defines the controller to be used for a custom update status action + # <tt>:action</tt>:: defines the action to be used for a custom update status action + # + # Parameters passed to the action defined by status_url_for_options + # + # <tt>:upload_id</tt>:: the upload_id automatically generated by +form_tag_with_upload_progress+ or the user defined id passed to this method. + # + def form_tag_with_upload_progress(url_for_options = {}, options = {}, status_url_for_options = {}, *parameters_for_url_method) + + ## Setup the action URL and the server-side upload_status action for + ## polling of status during the upload + + options = options.dup + + upload_id = url_for_options.delete(:upload_id) || next_upload_id + upload_action_url = url_for(url_for_options) + + if status_url_for_options.is_a? Hash + status_url_for_options = status_url_for_options.merge({ + :action => 'upload_status', + :upload_id => upload_id}) + end + + status_url = url_for(status_url_for_options) + + ## Prepare the form options. Dynamically change the target and URL to enable the status + ## updating only if javascript is enabled, otherwise perform the form submission in the same + ## frame. + + upload_target = options[:target] || upload_target_id + upload_id_param = "#{upload_action_url.include?('?') ? '&' : '?'}upload_id=#{upload_id}" + + ## Externally :begin and :finish are the entry and exit points + ## Internally, :finish is called :complete + + js_options = { + :decay => options[:decay] || FREQUENCY_DECAY, + :freqency => options[:frequency] || FREQUENCY, + } + + updater_options = '{' + js_options.map {|k, v| "#{k}:#{v}"}.sort.join(',') + '}' + + ## Finish off the updating by forcing the progress bar to 100% and status text because the + ## results of the post may load and finish in the IFRAME before the last status update + ## is loaded. + + options[:complete] = "$('#{status_tag_id}').innerHTML='#{escape_javascript upload_progress_text(:finish)}';" + options[:complete] << "#{upload_progress_update_bar_js(100)};" + options[:complete] << "#{upload_update_object} = null" + options[:complete] = "#{options[:complete]}; #{options[:finish]}" if options[:finish] + + options[:script] = true + + ## Prepare the periodic updater, clearing any previous updater + + updater = "if (#{upload_update_object}) { #{upload_update_object}.stop(); }" + updater << "#{upload_update_object} = new Ajax.PeriodicalUpdater('#{status_tag_id}'," + updater << "'#{status_url}', #{options_for_ajax(options)}.extend(#{updater_options}))" + + updater = "#{options[:begin]}; #{updater}" if options[:begin] + updater = "#{upload_progress_update_bar_js(0)}; #{updater}" + updater = "$('#{status_tag_id}').innerHTML='#{escape_javascript upload_progress_text(:begin)}'; #{updater}" + + ## Touch up the form action and target to use the given target instead of the + ## default one. Then start the updater + + options[:onsubmit] = "if (this.action.indexOf('upload_id') < 0){ this.action += '#{escape_javascript upload_id_param}'; }" + options[:onsubmit] << "this.target = '#{escape_javascript upload_target}';" + options[:onsubmit] << "#{updater}; return true" + options[:multipart] = true + + [:begin, :finish, :complete, :frequency, :decay, :script].each { |sym| options.delete(sym) } + + ## Create the tags + ## If a target for the form is given then avoid creating the hidden IFRAME + + tag = form_tag(upload_action_url, options, *parameters_for_url_method) + + unless options[:target] + tag << content_tag('iframe', '', { + :id => upload_target, + :name => upload_target, + :src => '', + :style => 'width:0px;height:0px;border:0' + }) + end + + tag + end + + # This method must be called by the action that receives the form post + # with the +upload_progress+. By default this method is rendered when + # the controller declares that the action is the receiver of a + # +form_tag_with_upload_progress+ posting. + # + # This template will do a javascript redirect to the URL specified in +redirect_to+ + # if this method is called anywhere in the controller action. When the action + # performs a redirect, the +finish+ handler will not be called. + # + # If there are errors in the action then you should set the controller + # instance variable +@errors+. The +@errors+ object will be + # converted to a javascript array from +@errors.full_messages+ and + # passed to the +finish+ handler of +form_tag_with_upload_progress+ + # + # If no errors have occured, the parameter to the +finish+ handler will + # be +undefined+. + # + # == Example (in view) + # + # <script> + # function do_finish(errors) { + # if (errors) { + # alert(errors); + # } + # } + # </script> + # + # <%= form_tag_with_upload_progress {:action => 'create'}, {finish => 'do_finish(arguments[0])'} %> + # + def finish_upload_status(options = {}) + # Always trigger the stop/finish callback + js = "parent.#{upload_update_object}.stop(#{options[:client_js_argument]});\n" + + # Redirect if redirect_to was called in controller + js << "parent.location.replace('#{escape_javascript options[:redirect_to]}');\n" unless options[:redirect_to].blank? + + # Guard against multiple triggers/redirects on back + js = "if (parent.#{upload_update_object}) { #{js} }\n" + + content_tag("html", + content_tag("head", + content_tag("script", "function finish() { #{js} }", + {:type => "text/javascript", :language => "javascript"})) + + content_tag("body", '', :onload => 'finish()')) + end + + # Renders the HTML to contain the upload progress bar above the + # default messages + # + # Use this method to display the upload status after your +form_tag_with_upload_progress+ + def upload_status_tag(content='', options={}) + upload_status_progress_bar_tag + upload_status_text_tag(content, options) + end + + # Content helper that will create a +div+ with the proper ID and class that + # will contain the contents returned by the +upload_status+ action. The container + # is defined as + # + # <div id="#{status_tag_id}" class="uploadStatus"> </div> + # + # Style this container by selecting the +.uploadStatus+ +CSS+ class. + # + # The +content+ parameter will be included in the inner most +div+ when + # rendered. + # + # The +options+ will create attributes on the outer most div. To use a different + # +CSS+ class, pass a different class option. + # + # Example +CSS+: + # .uploadStatus { font-size: 10px; color: grey; } + # + def upload_status_text_tag(content=nil, options={}) + content_tag("div", content, {:id => status_tag_id, :class => 'uploadStatus'}.merge(options)) + end + + # Content helper that will create the element tree that can be easily styled + # with +CSS+ to create a progress bar effect. The containers are defined as: + # + # <div class="progressBar" id="#{progress_bar_id}"> + # <div class="border"> + # <div class="background"> + # <div class="content"> </div> + # </div> + # </div> + # </div> + # + # The +content+ parameter will be included in the inner most +div+ when + # rendered. + # + # The +options+ will create attributes on the outer most div. To use a different + # +CSS+ class, pass a different class option. + # + # Example: + # <%= upload_status_progress_bar_tag('', {:class => 'progress'}) %> + # + # Example +CSS+: + # + # div.progressBar { + # margin: 5px; + # } + # + # div.progressBar div.border { + # background-color: #fff; + # border: 1px solid grey; + # width: 100%; + # } + # + # div.progressBar div.background { + # background-color: #333; + # height: 18px; + # width: 0%; + # } + # + def upload_status_progress_bar_tag(content='', options={}) + css = [options[:class], 'progressBar'].compact.join(' ') + + content_tag("div", + content_tag("div", + content_tag("div", + content_tag("div", content, :class => 'foreground'), + :class => 'background'), + :class => 'border'), + {:id => progress_bar_id}.merge(options).merge({:class => css})) + end + + # The text and Javascript returned by the default +upload_status+ controller + # action which will replace the contents of the div created by +upload_status_text_tag+ + # and grow the progress bar background to the appropriate width. + # + # See +upload_progress_text+ and +upload_progress_update_bar_js+ + def upload_progress_status + "#{upload_progress_text}<script>#{upload_progress_update_bar_js}</script>" + end + + # Javascript helper that will create a script that will change the width + # of the background progress bar container. Include this in the script + # portion of your view rendered by your +upload_status+ action to + # automatically find and update the progress bar. + # + # Example (in controller): + # + # def upload_status + # render :inline => "<script><%= update_upload_progress_bar_js %></script>", :layout => false + # end + # + # + def upload_progress_update_bar_js(percent=nil) + progress = upload_progress + percent ||= case + when progress.nil? || !progress.started? then 0 + when progress.finished? then 100 + else progress.completed_percent + end.to_i + + # TODO do animation instead of jumping + "$('#{progress_bar_id}').firstChild.firstChild.style.width='#{percent}%'" + end + + # Generates a nicely formatted string of current upload progress for + # +UploadProgress::Progress+ object +progress+. Addtionally, it + # will return "Upload starting..." if progress has not been initialized, + # "Receiving data..." if there is no received data yet, and "Upload + # finished" when all data has been sent. + # + # You can overload this method to add you own output to the + # + # Example return: 223.5 KB of 1.5 MB at 321.2 KB/s; less than 10 seconds + # remaining + def upload_progress_text(state=nil) + eval case + when state then @@default_messages[state.to_sym] + when upload_progress.nil? || !upload_progress.started? then @@default_messages[:begin] + when upload_progress.finished? then @@default_messages[:finish] + else @@default_messages[:update] + end + end + + protected + # Javascript object used to contain the polling methods and keep track of + # the finished state + def upload_update_object + "document.uploadStatus#{current_upload_id}" + end + + # Element ID of the progress bar + def progress_bar_id + "UploadProgressBar#{current_upload_id}" + end + + # Element ID of the progress status container + def status_tag_id + "UploadStatus#{current_upload_id}" + end + + # Element ID of the target <iframe> used as the target of the form + def upload_target_id + "UploadTarget#{current_upload_id}" + end + + end + end +end diff --git a/actionpack/test/controller/multipart_progress_test.rb b/actionpack/test/controller/multipart_progress_test.rb new file mode 100644 index 0000000000..ed2bdc9f6c --- /dev/null +++ b/actionpack/test/controller/multipart_progress_test.rb @@ -0,0 +1,365 @@ +require File.dirname(__FILE__) + '/../abstract_unit' +require 'logger' +require 'test/unit' +require 'cgi' +require 'stringio' + +DEBUG=false + +def test_logger + if DEBUG then ActionController::Base.logger = Logger.new(STDERR) + else ActionController::Base.logger = Logger.new(StringIO.new) + end +end + +# Provide a static version of the Controllers module instead of the auto-loading version. +# We don't want these tests to fail when dependencies are to blame. +module Controllers + class EmptyController < ActionController::Base + end + class ApplicationController < ActionController::Base + end + + class MockController < ActionController::Base + def initialize + super + @session = {:uploads => {}} + @params = {} + end + end + + class SingleUploadController < ActionController::Base + upload_status_for :one + + def one; end + end + + class DoubleUploadController < ActionController::Base + upload_status_for :one, :two + + def one; end + def two; end + end + + class DoubleStatusUploadController < ActionController::Base + upload_status_for :one, :two, :status => :custom_status + + def one; end + def two; end + end + + class DoubleSeperateController < ActionController::Base + upload_status_for :one + upload_status_for :two + + def one; end + def two; end + end + + class UploadController < ActionController::Base + upload_status_for :norendered, :rendered, :redirected, :finish_param_dict, :finish_param_string, :finish_param_number + + def norendered + end + + def rendered + render_text("rendered") + end + + def redirected + redirect_to "/redirected/" + end + + def finish_param_dict + finish_upload_status "{a: 'b'}" + end + + def finish_param_string + finish_upload_status "'a string'" + end + + def finish_param_number + finish_upload_status 123 + end + + def finish_param_number_redirect + redirect_to "/redirected/" + finish_upload_status 123 + end + end +end + +class MockIO < StringIO + def initialize(data='', &block) + test_logger.debug("MockIO inializing data: #{data[0..20]}") + + @block = block + super(data) + end + + def write(data) + test_logger.debug("MockIO write #{data.size} data: #{data[0..20]}") + super + end + def read(size) + test_logger.debug("MockIO getting data from super") + data = super + + test_logger.debug("Calling read callback") + @block.call + + test_logger.debug("Returning data: #{data.size}") + data + end +end + +class MockCGI < CGI + BOUNDARY = '----------0xKhTmLbOuNdArY' + FILENAME = 'dummy.nul' + + attr_reader :upload_id, :session_options, :session_id + + def initialize(size=1000, url='/test', &block) + @url = url + @env = {} + @sio = MockIO.new('') { block.call(self) if block_given? } + + @upload_id = '1' + + add_param('param1', 'value1') + add_data(size) + add_param('param1', 'value2') + add_end_boundary + init_env + @sio.rewind + super() + end + + #def stdinput_without_progress + # @sio + #end + + def stdinput + @sio + end + + def env_table + @env + end + + private + def init_env + @env['HTTP_HOST'] = 'localhost' + @env['SERVER_PORT'] = '80' + @env['REQUEST_METHOD'] = "POST" + @env['QUERY_STRING'] = @url.split('?')[1] || "upload_id=#{upload_id}&query_param=query_value" + @env['REQUEST_URI'] = @url + @env['SCRIPT_NAME'] = @url.split('?').first.split('/').last + @env['PATH_INFO'] = @url.split('?').first + @env['CONTENT_TYPE'] = "multipart/form-data; boundary=#{BOUNDARY}" + @env['CONTENT_LENGTH'] = @sio.tell - EOL.size + + @session_options = ActionController::CgiRequest::DEFAULT_SESSION_OPTIONS.inject({}) { |options, pair| + options[pair.first.to_s] = pair.last; options + } + session = CGI::Session.new({}, @session_options.merge({'new_session' => true})) + @session_id = session.session_id + @env['COOKIE'] = "_session_id=#{session.session_id}" + session.close + end + + def add_param(name, value) + add_boundary + @sio << "Content-Disposition: form-data; name=\"#{name}\"" << EOL << EOL + @sio << value.to_s << EOL + end + + def add_data(size) + add_boundary + @sio << "Content-Disposition: form-data; name=\"file\"; filename=\"#{FILENAME}\"" << EOL + @sio << "Content-Type: application/octet-stream" << EOL << EOL + @sio << "." * size + @sio << EOL + end + + def add_boundary + @sio << "--" << BOUNDARY << EOL + end + + def add_end_boundary + @sio << "--" << BOUNDARY << "--" << EOL + end +end + +class MultipartProgressTest < Test::Unit::TestCase + + def test_domain_language_single + c = Controllers::SingleUploadController.new + assert_respond_to(c, :one) + assert_respond_to(c, :upload_status) + assert_respond_to(c, :finish_upload_status) + end + + def test_domain_language_double + c = Controllers::DoubleUploadController.new + assert_respond_to(c, :one) + assert_respond_to(c, :two) + assert_respond_to(c, :upload_status) + assert_respond_to(c, :finish_upload_status) + end + + def test_domain_language_double_status + c = Controllers::DoubleStatusUploadController.new + assert_respond_to(c, :one) + assert_respond_to(c, :two) + assert_respond_to(c, :custom_status) + assert_respond_to(c, :finish_upload_status) + end + + def test_domain_language_double_seperate + c = Controllers::DoubleSeperateController.new + assert_respond_to(c, :one) + assert_respond_to(c, :two) + assert_respond_to(c, :upload_status) + assert_respond_to(c, :finish_upload_status) + end + + def test_finish_status_norendered + # Fails to render the upload finish script because there is no view associated with this action + test_logger.debug('test_finish_status_norendered') + + res = process(:action => 'norendered', :upload_id => 1) + assert_match(/ActionView::ActionViewError/s, res.body) + + res = process(:action => :upload_status, :upload_id => 1) + assert_match(/Upload finished/s, res.body) + + res = process(:action => :norendered) + assert_match(/ActionView::ActionViewError/s, res.body) + end + + def test_finish_status_rendered + test_logger.debug('test_finish_status_rendered') + + res = process(:action => :rendered, :upload_id => 1) + assert_match(/stop\(\)/s, res.body) + assert_no_match(/rendered/s, res.body) + + res = process(:action => :upload_status, :upload_id => 1) + assert_match(/Upload finished/s, res.body) + + res = process(:action => :rendered) + assert_no_match(/stop\(\)/s, res.body) + assert_match(/rendered/, res.body) + end + + def test_finish_status_redirected + test_logger.debug('test_finish_status_redirected') + + res = process(:action => :redirected, :upload_id => 1) + assert_match(/location\.replace/s, res.body) + + res = process(:action => :redirected) + assert_no_match(/location\.replace/s, res.body) + assert_match(/\/redirected\//s, res.headers['location']) + assert_match(/302 .*$/, res.headers['Status']) + + res = process(:action => :upload_status, :upload_id => 1) + assert_match(/Upload finished/s, res.body) + end + + def test_finish_status_finish_param + test_logger.debug('test_finish_status_param') + + res = process(:action => :finish_param_string, :upload_id => 1) + assert_match(/stop\('a string'\)/s, res.body) + assert_no_redirect res + + res = process(:action => :finish_param_dict, :upload_id => 1) + assert_match(/stop\(\{a: 'b'\}\)/s, res.body) + assert_no_redirect res + + res = process(:action => :finish_param_number, :upload_id => 1) + assert_match(/stop\(123\)/s, res.body) + assert_no_redirect res + + res = process(:action => :finish_param_number_redirect, :upload_id => 1) + test_logger.debug('test_finish_status_param: ' + res.body) + assert_match(/stop\(123\)/s, res.body) + assert_match(/replace\('\http:\/\/localhost\/redirected\/'\).*?/s, res.body) + assert_no_redirect res + end + + def test_basic_setup + test_logger.debug('test_basic_setup') + + cgi, request, response = new_request(100000) + assert_not_nil(request.session) + assert_not_nil(request.session[:uploads], "uploads collection not set") + assert_not_nil(request.session[:uploads][cgi.upload_id], "upload id not set") + progress = request.session[:uploads][cgi.upload_id] + assert_equal(true, progress.finished?) + end + + def test_params + test_logger.debug('test_params') + + cgi, request, response = new_request(1000) + assert(!request.params.empty?) + assert(!request.params['param1'].empty?) + end + + def test_share_session + cgi, request, response = new_request(100000) do |cgi, req| + if cgi.stdinput.tell > 50000 + # force a save + cgi.stdinput.save_progress rescue flunk('Something else is wrong, our wrapper isnt setup, is ActionController::Base.logger set?') + + other_session = CGI::Session.new(cgi, cgi.session_options.merge({'session_id' => cgi.session_id})) + assert_not_nil(other_session[:uploads]) + assert_not_nil(other_session[:uploads][cgi.upload_id]) + assert_in_delta(cgi.stdinput.session[:uploads][cgi.upload_id].bitrate, other_session[:uploads][cgi.upload_id].bitrate, 1000.0, "Seperate session does not share data from original session") + + other_session.close + end + end + end + + def test_upload_ids + c = Controllers::MockController.new + (1..222).each do |id| + c.params = {} + + assert_equal((id-1).to_s, c.last_upload_id, "last_upload_id is out of sync") + assert_equal(id.to_s, c.next_upload_id, "next_upload_id is out of sync") + assert_equal(id.to_s, c.current_upload_id, "current_upload_id is out of sync") + + c.params = {:upload_id => (id-1).to_s} + assert_equal((id-1).to_s, c.current_upload_id, "current_upload_id is out of sync") + + c.session[:uploads][id] = {} + end + end + + private + def new_request(size=1000, url='/test', &block) + test_logger.debug('Creating MockCGI') + cgi = MockCGI.new(size, url) do |cgi| + block.call(cgi) if block_given? + end + + assert(cgi.private_methods.include?("read_multipart_with_progress")) + return [cgi, ActionController::CgiRequest.new(cgi), ActionController::CgiResponse.new(cgi)] + end + + def process(options = {}) + Controllers::UploadController.process(*(new_request(1000, '/upload?' + options.map {|k,v| "#{k}=#{v}"}.join('&'))[1..2])) + end + + def assert_no_redirect(res) + assert_nil(res.redirected_to) + assert_nil(res.headers['location']) + assert_match(/200 .*$/, res.headers['Status']) + end + +end diff --git a/actionpack/test/controller/upload_progress_test.rb b/actionpack/test/controller/upload_progress_test.rb new file mode 100644 index 0000000000..48b4251f1d --- /dev/null +++ b/actionpack/test/controller/upload_progress_test.rb @@ -0,0 +1,89 @@ +require File.dirname(__FILE__) + '/../abstract_unit' +require 'test/unit' +require 'cgi' +require 'stringio' + +class UploadProgressTest < Test::Unit::TestCase + def test_remaining + progress = new_progress(20000) + assert_equal(0, progress.received_bytes) + assert_equal(20000, progress.remaining_bytes) + progress.update!(10000, 1.0) + assert_equal(10000, progress.remaining_bytes) + assert_equal(1.0, progress.remaining_seconds) + assert_equal(50, progress.completed_percent) + assert_equal(true, progress.started?) + assert_equal(false, progress.finished?) + assert_equal(false, progress.stalled?) + progress.update!(10000, 2.0) + assert_equal(true, progress.finished?) + assert_equal(0.0, progress.remaining_seconds) + end + + def test_stalled + progress = new_progress(10000) + assert_equal(false, progress.stalled?) + progress.update!(100, 1.0) + assert_equal(false, progress.stalled?) + progress.update!(100, 20.0) + assert_equal(true, progress.stalled?) + assert_in_delta(0.0, progress.bitrate, 0.001) + progress.update!(100, 21.0) + assert_equal(false, progress.stalled?) + end + + def test_elapsed + progress = new_progress(10000) + (1..5).each do |t| + progress.update!(1000, Float(t)) + end + assert_in_delta(5.0, progress.elapsed_seconds, 0.001) + assert_equal(10000, progress.total_bytes) + assert_equal(5000, progress.received_bytes) + assert_equal(5000, progress.remaining_bytes) + end + + def test_overflow + progress = new_progress(10000) + progress.update!(20000, 1.0) + assert_equal(10000, progress.received_bytes) + end + + def test_zero + progress = new_progress(0) + assert_equal(0, progress.total_bytes) + assert_equal(0, progress.remaining_bytes) + assert_equal(false, progress.started?) + assert_equal(true, progress.finished?) + assert_equal(0, progress.bitrate) + assert_equal(0, progress.completed_percent) + assert_equal(0, progress.remaining_seconds) + end + + def test_finished + progress = new_progress(10000) + (1..9).each do |t| + progress.update!(1000, Float(t)) + assert_equal(false, progress.finished?) + assert_equal(1000.0, progress.bitrate) + assert_equal(false, progress.stalled?) + end + assert_equal(false, progress.finished?) + progress.update!(1000, 10.0) + assert_equal(true, progress.finished?) + end + + def test_rapid_samples + progress = new_progress(10000) + (1..1000).each do |t| + progress.update!(10, t/100.0) + end + assert_in_delta(1000.0, progress.bitrate, 0.001) + assert_equal(true, progress.finished?) + end + + private + def new_progress(total) + ActionController::UploadProgress::Progress.new(total) + end +end diff --git a/actionpack/test/template/upload_progress_helper_test.rb b/actionpack/test/template/upload_progress_helper_test.rb new file mode 100644 index 0000000000..361aa4bb08 --- /dev/null +++ b/actionpack/test/template/upload_progress_helper_test.rb @@ -0,0 +1,272 @@ +require File.dirname(__FILE__) + '/../abstract_unit' + +require File.dirname(__FILE__) + '/../../lib/action_view/helpers/date_helper' +require File.dirname(__FILE__) + '/../../lib/action_view/helpers/number_helper' +require File.dirname(__FILE__) + '/../../lib/action_view/helpers/asset_tag_helper' +require File.dirname(__FILE__) + '/../../lib/action_view/helpers/form_tag_helper' +require File.dirname(__FILE__) + '/../../lib/action_view/helpers/tag_helper' +require File.dirname(__FILE__) + '/../../lib/action_view/helpers/javascript_helper' +require File.dirname(__FILE__) + '/../../lib/action_view/helpers/upload_progress_helper' +require File.dirname(__FILE__) + '/../../../activesupport/lib/active_support/core_ext/hash' #for stringify keys + +class MockProgress + def initialize(started, finished) + @started, @finished = [started, finished] + end + + def started? + @started + end + + def finished? + @finished + end + + def message + "A message" + end + + def method_missing(meth, *args) + # Just return some consitant number + meth.to_s.hash.to_i.abs + args.hash.to_i.abs + end +end + +class UploadProgressHelperTest < Test::Unit::TestCase + include ActionView::Helpers::DateHelper + include ActionView::Helpers::NumberHelper + include ActionView::Helpers::AssetTagHelper + include ActionView::Helpers::FormTagHelper + include ActionView::Helpers::TagHelper + include ActionView::Helpers::UrlHelper + include ActionView::Helpers::JavascriptHelper + include ActionView::Helpers::UploadProgressHelper + + def next_upload_id; @upload_id = last_upload_id.succ; end + def last_upload_id; @upload_id ||= 0; end + def current_upload_id; last_upload_id; end + def upload_progress(upload_id = nil); @upload_progress or MockProgress.new(false, true); end + + def setup + @controller = Class.new do + def url_for(options, *parameters_for_method_reference) + "http://www.example.com" + end + end + @controller = @controller.new + end + + def test_upload_status_tag + assert_equal( + '<div class="progressBar" id="UploadProgressBar0"><div class="border"><div class="background"><div class="foreground"></div></div></div></div><div class="uploadStatus" id="UploadStatus0"></div>', + upload_status_tag + ) + end + + def test_upload_status_text_tag + assert_equal( + '<div class="my-upload" id="my-id">Starting</div>', + upload_status_text_tag('Starting', :class => 'my-upload', :id => 'my-id') + ) + end + + + def test_upload_progress_text + @upload_progress = MockProgress.new(false, false) + assert_equal( + "Upload starting...", + upload_progress_text + ) + + @upload_progress = MockProgress.new(true, false) + assert_equal( + "828.7 MB of 456.2 MB at 990.1 MB/s; 10227 days remaining", + upload_progress_text + ) + + @upload_progress = MockProgress.new(true, true) + assert_equal( + "Upload finished. A message", + upload_progress_text + ) + end + + def test_upload_progress_update_bar_js + assert_equal( + "$('UploadProgressBar0').firstChild.firstChild.style.width='0%';", + upload_progress_update_bar_js + ) + + assert_equal( + "$('UploadProgressBar0').firstChild.firstChild.style.width='50%';", + upload_progress_update_bar_js(50) + ) + end + + def test_finish_upload_status + assert_equal( + "<html><head><script language=\"javascript\" type=\"text/javascript\">function finish() { if (parent.document.uploadStatus0) { parent.document.uploadStatus0.stop();\n }\n }</script></head><body onload=\"finish()\"></body></html>", + finish_upload_status + ) + + assert_equal( + "<html><head><script language=\"javascript\" type=\"text/javascript\">function finish() { if (parent.document.uploadStatus0) { parent.document.uploadStatus0.stop(123);\n }\n }</script></head><body onload=\"finish()\"></body></html>", + finish_upload_status(:client_js_argument => 123) + ) + + assert_equal( + "<html><head><script language=\"javascript\" type=\"text/javascript\">function finish() { if (parent.document.uploadStatus0) { parent.document.uploadStatus0.stop();\nparent.location.replace('/redirected/');\n }\n }</script></head><body onload=\"finish()\"></body></html>", + finish_upload_status(:redirect_to => '/redirected/') + ) + end + + def test_form_tag_with_upload_progress + assert_equal( + "<form action=\"http://www.example.com\" enctype=\"multipart/form-data\" method=\"post\" onsubmit=\"if (this.action.indexOf('upload_id') < 0){ this.action += '?upload_id=1'; }this.target = 'UploadTarget1';$('UploadProgressBar1').firstChild.firstChild.style.width='0%';; document.uploadStatus1 = new Ajax.PeriodicalUpdater('UploadStatus1','http://www.example.com',{script:true, onComplete:function(request){$('UploadProgressBar1').firstChild.firstChild.style.width='100%';; }, asynchronous:true}.extend({decay:1.8,freqency:2.0})); return true\"><iframe id=\"UploadTarget1\" name=\"UploadTarget1\" src=\"\" style=\"width:0px;height:0px;border:0\"></iframe>", + form_tag_with_upload_progress + ) + end + + def test_form_tag_with_upload_progress_custom + assert_equal( + "<form action=\"http://www.example.com\" enctype=\"multipart/form-data\" method=\"post\" onsubmit=\"if (this.action.indexOf('upload_id') < 0){ this.action += '?upload_id=5'; }this.target = 'awindow';$('UploadProgressBar0').firstChild.firstChild.style.width='0%';; alert('foo'); document.uploadStatus0 = new Ajax.PeriodicalUpdater('UploadStatus0','http://www.example.com',{script:true, onComplete:function(request){$('UploadProgressBar0').firstChild.firstChild.style.width='100%';; alert('bar'); alert('bar')}, asynchronous:true}.extend({decay:7,freqency:6})); return true\" target=\"awindow\">", + form_tag_with_upload_progress({:upload_id => 5}, {:begin => "alert('foo')", :finish => "alert('bar')", :frequency => 6, :decay => 7, :target => 'awindow'}) + ) + end +end +require File.dirname(__FILE__) + '/../abstract_unit' + +require File.dirname(__FILE__) + '/../../lib/action_view/helpers/date_helper' +require File.dirname(__FILE__) + '/../../lib/action_view/helpers/number_helper' +require File.dirname(__FILE__) + '/../../lib/action_view/helpers/asset_tag_helper' +require File.dirname(__FILE__) + '/../../lib/action_view/helpers/form_tag_helper' +require File.dirname(__FILE__) + '/../../lib/action_view/helpers/tag_helper' +require File.dirname(__FILE__) + '/../../lib/action_view/helpers/javascript_helper' +require File.dirname(__FILE__) + '/../../lib/action_view/helpers/upload_progress_helper' +require File.dirname(__FILE__) + '/../../../activesupport/lib/active_support/core_ext/hash' #for stringify keys + +class MockProgress + def initialize(started, finished) + @started, @finished = [started, finished] + end + + def started? + @started + end + + def finished? + @finished + end + + def message + "A message" + end + + def method_missing(meth, *args) + # Just return some consitant number + meth.to_s.hash.to_i.abs + args.hash.to_i.abs + end +end + +class UploadProgressHelperTest < Test::Unit::TestCase + include ActionView::Helpers::DateHelper + include ActionView::Helpers::NumberHelper + include ActionView::Helpers::AssetTagHelper + include ActionView::Helpers::FormTagHelper + include ActionView::Helpers::TagHelper + include ActionView::Helpers::UrlHelper + include ActionView::Helpers::JavascriptHelper + include ActionView::Helpers::UploadProgressHelper + + def next_upload_id; @upload_id = last_upload_id.succ; end + def last_upload_id; @upload_id ||= 0; end + def current_upload_id; last_upload_id; end + def upload_progress(upload_id = nil); @upload_progress ||= MockProgress.new(false, true); end + + def setup + @controller = Class.new do + def url_for(options, *parameters_for_method_reference) + "http://www.example.com" + end + end + @controller = @controller.new + end + + def test_upload_status_tag + assert_equal( + '<div class="progressBar" id="UploadProgressBar0"><div class="border"><div class="background"><div class="foreground"></div></div></div></div><div class="uploadStatus" id="UploadStatus0"></div>', + upload_status_tag + ) + end + + def test_upload_status_text_tag + assert_equal( + '<div class="my-upload" id="my-id">Starting</div>', + upload_status_text_tag('Starting', :class => 'my-upload', :id => 'my-id') + ) + end + + + def test_upload_progress_text + @upload_progress = MockProgress.new(false, false) + assert_equal( + "Upload starting...", + upload_progress_text + ) + + @upload_progress = MockProgress.new(true, false) + assert_equal( + "828.7 MB of 456.2 MB at 990.1 MB/s; 10227 days remaining", + upload_progress_text + ) + + @upload_progress = MockProgress.new(true, true) + assert_equal( + "A message", + upload_progress_text + ) + end + + def test_upload_progress_update_bar_js + assert_equal( + "$('UploadProgressBar0').firstChild.firstChild.style.width='0%'", + upload_progress_update_bar_js + ) + + assert_equal( + "$('UploadProgressBar0').firstChild.firstChild.style.width='50%'", + upload_progress_update_bar_js(50) + ) + end + + def test_finish_upload_status + assert_equal( + "<html><head><script language=\"javascript\" type=\"text/javascript\">function finish() { if (parent.document.uploadStatus0) { parent.document.uploadStatus0.stop();\n }\n }</script></head><body onload=\"finish()\"></body></html>", + finish_upload_status + ) + + assert_equal( + "<html><head><script language=\"javascript\" type=\"text/javascript\">function finish() { if (parent.document.uploadStatus0) { parent.document.uploadStatus0.stop(123);\n }\n }</script></head><body onload=\"finish()\"></body></html>", + finish_upload_status(:client_js_argument => 123) + ) + + assert_equal( + "<html><head><script language=\"javascript\" type=\"text/javascript\">function finish() { if (parent.document.uploadStatus0) { parent.document.uploadStatus0.stop();\nparent.location.replace('/redirected/');\n }\n }</script></head><body onload=\"finish()\"></body></html>", + finish_upload_status(:redirect_to => '/redirected/') + ) + end + + def test_form_tag_with_upload_progress + assert_equal( + "<form action=\"http://www.example.com\" enctype=\"multipart/form-data\" method=\"post\" onsubmit=\"if (this.action.indexOf('upload_id') < 0){ this.action += '?upload_id=1'; }this.target = 'UploadTarget1';$('UploadStatus1').innerHTML='Upload starting...'; $('UploadProgressBar1').firstChild.firstChild.style.width='0%'; if (document.uploadStatus1) { document.uploadStatus1.stop(); }document.uploadStatus1 = new Ajax.PeriodicalUpdater('UploadStatus1','http://www.example.com', {script:true, onComplete:function(request){$('UploadStatus1').innerHTML='A message';$('UploadProgressBar1').firstChild.firstChild.style.width='100%';document.uploadStatus1 = null}, asynchronous:true}.extend({decay:1.8,freqency:2.0})); return true\"><iframe id=\"UploadTarget1\" name=\"UploadTarget1\" src=\"\" style=\"width:0px;height:0px;border:0\"></iframe>", + form_tag_with_upload_progress + ) + end + + def test_form_tag_with_upload_progress_custom + assert_equal( + "<form action=\"http://www.example.com\" enctype=\"multipart/form-data\" method=\"post\" onsubmit=\"if (this.action.indexOf('upload_id') < 0){ this.action += '?upload_id=5'; }this.target = 'awindow';$('UploadStatus0').innerHTML='Upload starting...'; $('UploadProgressBar0').firstChild.firstChild.style.width='0%'; alert('foo'); if (document.uploadStatus0) { document.uploadStatus0.stop(); }document.uploadStatus0 = new Ajax.PeriodicalUpdater('UploadStatus0','http://www.example.com', {script:true, onComplete:function(request){$('UploadStatus0').innerHTML='A message';$('UploadProgressBar0').firstChild.firstChild.style.width='100%';document.uploadStatus0 = null; alert('bar')}, asynchronous:true}.extend({decay:7,freqency:6})); return true\" target=\"awindow\">", + form_tag_with_upload_progress({:upload_id => 5}, {:begin => "alert('foo')", :finish => "alert('bar')", :frequency => 6, :decay => 7, :target => 'awindow'}) + ) + end +end |