Ajax Upload for Tapestry

Tapestry has an excellent support for JavaScript and Ajax. It strikes a perfect balance between how much a framework should assist and how much the developer should do. Most component-based frameworks, rather than assisting, supervise and most action-based frameworks leave even the integration to the developer. Tapestry provides you with events which you can easily connect to your JavaScript events/functions and all the rest is, as I keep on saying, magic.

In this post, I will talk about integrating an Ajax based upload library, file-uploader with Tapestry.

Ajax Upload

Ajax based upload is not fully supported by all browsers. So this library falls back to iframe based form submission in case the browser does not support Ajax upload. If you want to know more about Ajax-upload you can read the very well written source code of this library along with this.

Although this library is very good but it lacks a few features which I had to add myself.

  1. Once an attachment is uploaded, there is no way to remove that attachment.
  2. If the form is to be resubmitted, the previous uploaded files should be displayed as well
  3. The text displayed on the buttons is not configurable

With such a nicely written script, it was quite easy to accomplish this. I am not going to discuss the modifications except for those which are directly related to the integration.

Decoding the request

The upload request can come in two forms, either as an Ajax-request with input stream as the uploaded file or as a Multipart request. The latter is already taken care of by tapestry-upload module’s MultipartServletRequestFilter which checks if the request is multipart, and in case it is, then decodes it using MultipartDecoder. The decoder uses the Apache commons upload library to decode the request and stores the uploaded files in the form of UploadedFile. The only thing that compelled me to override MultipartDecoder service is the cleanup it does at the end of the request. As Ajax upload will be using multiple requests(one for each upload and one of form submission), the cleanup has to be prevented, so in the Module class we override the service


   @Scope(ScopeConstants.PERTHREAD)
   public static MultipartDecoder buildMultipartDecoder2(RegistryShutdownHub shutdownHub,
         @Autobuild MultipartDecoderImpl multipartDecoder)
   {

      if (needToAddShutdownListener.getAndSet(false))
      {
         shutdownHub.addRegistryShutdownListener(new RegistryShutdownListener()
         {
            @Override
            public void registryDidShutdown()
            {
               FileCleaner.exitWhenFinished();
            }
         });
      }

      return multipartDecoder;
   }


   public static void contributeServiceOverride(@InjectService("MultipartDecoder2") MultipartDecoder multipartDecoder,
         @SuppressWarnings("rawtypes") MappedConfiguration<Class, Object> overrides)
   {
      overrides.add(MultipartDecoder.class, multipartDecoder);
   }

The Ajax upload request is decoded in the same way. There is a AjaxUploadServletRequestFilter which checks if the request is an Ajax upload request and in case it is then passes the request to AjaxUploadDecoder for extracting the uploaded file.

public class AjaxUploadServletRequestFilter implements HttpServletRequestFilter
{

   private AjaxUploadDecoder decoder;

   public AjaxUploadServletRequestFilter(AjaxUploadDecoder decoder)
   {
      this.decoder = decoder;
   }

   @Override
   public boolean service(HttpServletRequest request, 
          HttpServletResponse response, HttpServletRequestHandler handler)
         throws IOException
   {
      if (decoder.isAjaxUploadRequest(request))
      {
         decoder.setupUploadedFile(request);
      }

      return handler.service(request, response);
   }

}

The interface and implementation of Ajax upload decoder is below

public interface AjaxUploadDecoder
{
   
   boolean isAjaxUploadRequest(HttpServletRequest request);
   
   boolean isAjaxUploadRequest(Request request);
   
   UploadedFile getFileUpload();
   
   void setupUploadedFile(HttpServletRequest request);
   
}

public class AjaxUploadDecoderImpl implements AjaxUploadDecoder
{
   private UploadedFileItem uploadedFile;
   
   public static final String AJAX_UPLOAD_HEADER = "X-File-Name"; 

   private FileItemFactory fileItemFactory;

   public AjaxUploadDecoderImpl(FileItemFactory fileItemFactory)
   {
      this.fileItemFactory = fileItemFactory;
   }

   @Override
   public boolean isAjaxUploadRequest(HttpServletRequest request)
   {
      return request.getHeader(AJAX_UPLOAD_HEADER) != null;
   }
   
   @Override
   public boolean isAjaxUploadRequest(Request request)
   {
      return request.isXHR() && request.getHeader(AJAX_UPLOAD_HEADER) != null;
   }

   @Override
   public void setupUploadedFile(HttpServletRequest request)
   {
      String fieldName = request.getHeader(AJAX_UPLOAD_HEADER);
      FileItem item = fileItemFactory.createItem(fieldName, 
            request.getContentType(), false,
            request.getParameter(AjaxUploadConstants.FILE_PARAMETER));
      try
      {
         TapestryInternalUtils.copy(request.getInputStream(), item.getOutputStream());
      }
      catch (IOException e)
      {
         throw new RuntimeException("Could not copy request's input stream to file", e);
      }

      uploadedFile = new UploadedFileItem(item);
   }

   @Override
   public UploadedFile getFileUpload()
   {
      return uploadedFile;
   }

}

The main job of creating the upload is delegated to the FileItemFactory of apache commons upload library.

Integration

The upload script requires the following arguments :-

  1. element: Element which will be used for creating the component
  2. action: URL to which the file will be sent. The script expects a JSON in respose with success = true for successful upload. In case of an error, an error field is expected to contain the error message
  3. cancelLink: URL which will be called when upload is cancelled
  4. removeLink: URL which will be called when an uploaded file is to be removed
  5. initializeUploadsLink: URL which will be called when the component is loaded. The script expects a JSON containing a list of initially uploaded files, in case of resubmission
  6. sizeLimit: Maximum size of file allowed to be uploaded
  7. name: Name of the file field created
  8. uploadText: Text to be displayed on the upload button
  9. dropText: Text to be displayed on the drop area, in case of a drag-n-drop

All this is done by the component AjaxUpload

@SupportsInformalParameters
@Import(library = "ajaxupload.js", stylesheet = "ajaxupload.css")
public class AjaxUpload extends AbstractField
{

   public static final String FILE_PARAMETER = "qqfile";
   private static final String STYLE_TO_HIDE_INPUT_TEXT = "display:inline;"
         + "color:transparent;background:transparent;" + "border:0;height:1px;width:1px;";

   @Inject
   private JavaScriptSupport javaScriptSupport;

   @Inject
   private ComponentResources resources;

   @Parameter(required = true, autoconnect = true, principal = true)
   private List<UploadedFile> value;

   @SuppressWarnings("unused")
   @Parameter
   private boolean uploaded;

   @Symbol(UploadSymbols.REQUESTSIZE_MAX)
   @Inject
   private int maxSize;

   @Parameter(value = "1", defaultPrefix = BindingConstants.LITERAL)
   private int maxFiles;

   @Inject
   private Request request;

   @Inject
   private Messages messages;

   @Inject
   private MultipartDecoder multipartDecoder;

   @Inject
   private AjaxUploadDecoder ajaxDecoder;

   /**
    * The object that will perform input validation. The "validate:" binding
    * prefix is generally used to provide this object in a declarative fashion.
    */
   @Parameter(defaultPrefix = BindingConstants.VALIDATE)
   private FieldValidator<Object> validate;

   @Environmental
   private ValidationTracker tracker;

   @SuppressWarnings("unused")
   @Environmental
   private FormSupport formSupport;

   @Inject
   private ComponentDefaultProvider defaultProvider;

   @Inject
   private FieldValidationSupport fieldValidationSupport;

   @SuppressWarnings("unused")
   @Mixin
   private RenderDisabled renderDisabled;

   /**
    * Computes a default value for the "validate" parameter using
    * {@link FieldValidatorDefaultSource}.
    */
   final Binding defaultValidate()
   {
      return defaultProvider.defaultValidatorBinding("value", resources);
   }

   public AjaxUpload()
   {
   }

   // For testing
   AjaxUpload(List<UploadedFile> value, FieldValidator<Object> validate, 
         MultipartDecoder multipartDecoder,
         AjaxUploadDecoder ajaxDecoder, 
         ValidationTracker tracker, ComponentResources resources,
         FieldValidationSupport fieldValidationSupport, 
         JavaScriptSupport javaScriptSupport)
   {
      this.value = value;
      if (validate != null)
         this.validate = validate;
      this.multipartDecoder = multipartDecoder;
      this.tracker = tracker;
      this.resources = resources;
      this.fieldValidationSupport = fieldValidationSupport;
      this.javaScriptSupport = javaScriptSupport;
      this.ajaxDecoder = ajaxDecoder;
      maxFiles = 1;
   }

   void beginRender(MarkupWriter writer)
   {
      writer.element("input", "type", "text", "id", 
          getClientId(), "style", STYLE_TO_HIDE_INPUT_TEXT, "name",
            getControlName());
      validate.render(writer);
      decorateInsideField();
   }

   private String getWrapperClientId()
   {
      return getClientId() + "_wrapper";
   }

   private String getFileControlName()
   {
      return getControlName() + "_file";
   }

   void afterRender(final MarkupWriter writer)
   {
      writer.end();
      writer.element("span", "id", getWrapperClientId(), "style", "display:inline-block");
      writer.end();
   }

   @AfterRender
   void addJavaScript()
   {
      JSONObject arguments = fillArguments();
      javaScriptSupport.addScript("new qq.FileUploader(%s);", arguments);
   }

   JSONObject fillArguments()
   {
      JSONObject spec = new JSONObject();
      for (String informalParameter : resources.getInformalParameterNames())
      {
         spec.put(informalParameter, 
           resources.getInformalParameter(informalParameter, String.class));
      }

      spec.put("element", getElementId());
      spec.put("sizeField", getControlName());
      spec.put("uploadText", messages.get("ajaxupload.upload-text"));
      spec.put("dropText", messages.get("ajaxupload.drop-text"));
      spec.put("action", getUploadLink());
      spec.put("cancelLink", getCancelLink());
      spec.put("removeLink", getRemoveLink());
      spec.put("initializeUploadsLink", getInitializeUploadsLink());
      spec.put("sizeLimit", maxSize < 0 ? 0 : maxSize);
      spec.put("name", getFileControlName());
      spec.put("id", getFileControlName());
      return spec;
   }

   private Object getElementId()
   {
      return new JSONLiteral("document.getElementById('" + getWrapperClientId() + "')");
   }

   String getUploadLink()
   {
      final Link link = resources.createEventLink("upload");
      return link.toAbsoluteURI();
   }

   String getCancelLink()
   {
      final Link cancelLink = resources.createEventLink("cancelUpload");
      return cancelLink.toAbsoluteURI();
   }

   String getRemoveLink()
   {
      final Link removeLink = resources.createEventLink("removeUpload");
      return removeLink.toAbsoluteURI();
   }

   String getInitializeUploadsLink()
   {
      final Link initializeUploadsLink = resources.createEventLink("initializeUploads");
      return initializeUploadsLink.toAbsoluteURI();
   }

   /**
    * Converts the current list of uploaded files to a JSON object containing a json array
    *  with each element containing the name of the file and a unique key for
    * identification. The unique key is index of the uploaded file in parameter
    * <code>value</code>
    * 
    * @return
    */
   JSONObject onInitializeUploads()
   {
      JSONArray array = new JSONArray();
      if (value != null)
      {
         for (int i = 0; i < value.size(); ++i)
         {
            if (value.get(i) == null)
            {
               continue;
            }
            JSONObject indexWithFileName = new JSONObject();
            indexWithFileName.put("serverIndex", i);
            indexWithFileName.put("fileName", value.get(i).getFilePath());
            array.put(indexWithFileName);
         }
      }
      return new JSONObject().put("uploads", array);
   }

   Object onUpload()
   {
      if (hasMaximumFileUploadCountReached())
      {
         return createFailureResponse(messages.format("ajaxupload.maxfiles", maxFiles));
      }

      final UploadedFile uploadedFile;
      if (isAjaxUpload())
      {
         uploadedFile = createUploadedFileFromRequestInputStream();
      }
      else
      {
         uploadedFile = createUploadedFileFromMultipartForm();
      }

      if (value == null)
      {
         value = new ArrayList<UploadedFile>();
      }

      value.add(uploadedFile);

      return createSuccessResponse(value.size() - 1); // Last index
   }

   private boolean hasMaximumFileUploadCountReached()
   {
      if (value == null)
      {
         return maxFiles <= 0;
      }

      // Can't rely on value's size as some of the values can be null
      int size = 0;
      for (UploadedFile uploadedFile : value)
      {
         if (uploadedFile != null)
         {
            size++;
         }
      }
      return this.maxFiles <= size;
   }

   private boolean isAjaxUpload()
   {
      return ajaxDecoder.isAjaxUploadRequest(request);
   }

   private UploadedFile createUploadedFileFromMultipartForm()
   {
      return multipartDecoder.getFileUpload(FILE_PARAMETER);
   }

   private UploadedFile createUploadedFileFromRequestInputStream()
   {
      return ajaxDecoder.getFileUpload();
   }

   private Object createFailureResponse(String errorMessage)
   {
      JSONObject response = new JSONObject();
      response.put("success", false);
      response.put("error", errorMessage);
      if (!request.isXHR())
      {
         return new StatusResponse(response.toString());
      }
      else
      {
         return response;
      }
   }

   private Object createSuccessResponse(int serverIndex)
   {
      JSONObject response = new JSONObject();
      response.put("success", true);
      response.put("serverIndex", serverIndex);
      if (!request.isXHR())
      {
         return new StatusResponse(response.toString());
      }
      else
      {
         return response;
      }
   }

   void onRemoveUpload(@RequestParameter("serverIndex")int serverIndex)
   {
      // We use index of an uploadedFile in 'value' as a key at
      // the client side and if the uploaded file is removed we cleanup and set
      // the element at that index to null. As the 'value' may contain null, we
      // need to remove those entries in processSubmission()
      if (value != null && serverIndex >= 0 && serverIndex < value.size())
      {
         UploadedFile item = value.get(serverIndex);
         if (item != null && (item instanceof UploadedFileItem))
         {
            ((UploadedFileItem) item).cleanup();
         }
         value.set(serverIndex, null);
      }
   }

   void onCancelUpload(String fileName)
   {
      // TODO: Some how remove the partially uploaded file
   }

   @Override
   protected void processSubmission(String elementName)
   {
      // Nothing to process from current request as the uploads have already
      // been received and stored in value
      if (value != null)
      {
         // Remove any null values in 'value'
         removeNullsFromValue();
      }

      try
      {
         fieldValidationSupport.validate(value, resources, validate);
      }
      catch (ValidationException ex)
      {
         tracker.recordError(this, ex.getMessage());
      }
   }

   private void removeNullsFromValue()
   {
      List<UploadedFile> uploads = new ArrayList<UploadedFile>();
      for (UploadedFile upload : value)
      {
         if (upload != null)
         {
            uploads.add(upload);
         }
      }
      value = uploads;
   }

   public List<UploadedFile> getValue()
   {
      return value;
   }

   @Override
   public boolean isRequired()
   {
      return value != null && value.size() > 0;
   }

   AjaxUpload injectDecorator(ValidationDecorator decorator)
   {
      setDecorator(decorator);

      return this;
   }

   AjaxUpload injectRequest(Request request)
   {
      this.request = request;

      return this;
   }

   AjaxUpload injectFormSupport(FormSupport formSupport)
   {
      // We have our copy ...
      this.formSupport = formSupport;

      // As does AbstractField
      setFormSupport(formSupport);

      return this;
   }

   AjaxUpload injectFieldValidator(FieldValidator<Object> validator)
   {
      this.validate = validator;

      return this;
   }

   void injectResources(ComponentResources resources)
   {
      this.resources = resources;
   }

   void injectValue(List<UploadedFile> value)
   {
      this.value = value;
   }

   void injectMessages(Messages messages)
   {
      this.messages = messages;
   }

   static class StatusResponse implements StreamResponse
   {

      private String text;

      StatusResponse(String text)
      {
         this.text = text;
      }

      @Override
      public String getContentType()
      {
         return "text/html";
      }

      @Override
      public InputStream getStream() throws IOException
      {
         return new ByteArrayInputStream(text.toString().getBytes());
      }

      @Override
      public void prepareResponse(Response response)
      {

      }

      public JSONObject getJSON()
      {
         return new JSONObject(text);
      }
   }

   public void injectFieldValidationSupport(FieldValidationSupport support)
   {
      this.fieldValidationSupport = support;
   }

}

Note, I have embedded a textfield into the component. The reason for doing this is to facilitate client-side validation support, which requires an input(as it uses the form attribute of the component, hidden can’t be used as it is not visible).

In the upload request, we check to see the type of request and then get the uploaded file from the corresponding decoder. We send the index of the uploaded file to the script which is used for uniquely identifying the uploaded file. When the file is to be removed, this index is sent with the request and based on that index, the file is removed.

Usage

It can be used in the template as

<form t:type='form'>
   <input t:type='tawus/ajaxupload' t:id='uploads'/>
</form>
[/sourecode]

and in the class file

1

@Persist
@Property
private List<UploadedFile> uploads;

void onSuccess(){
   //Use uploads
   uploads.clear();
}

The value has to be persisted at the value has to live multiple requests.

You can find the module here

About these ads

Tagged: , ,

13 thoughts on “Ajax Upload for Tapestry

  1. Angelo June 26, 2011 at 1:55 PM Reply

    nice component, can user enter some descriptions for every file uploaded?

    • tawus June 26, 2011 at 2:44 PM Reply

      The filename is displayed for each upload. You can run the integration test for a demo

  2. Andro July 13, 2011 at 3:41 PM Reply

    Some problems when I use Chrome browser(version 13/14) to upload files. The first time I upload two files to server, but server get only one uploaded file. And after the first time uploading, I try again to upload two files, this time server could get the correct files.
    When I use Chrome (Version : 13/14 dev) to upload multiple files , server could get the upload files list , but every file in this list is the same.

    • tawus July 15, 2011 at 5:03 AM Reply

      Which version did you try. The latest is at https://github.com/tawus/tawus/tree/master/tawus-ajaxupload. I usually don’t update the version at https://github.com/tawus/javamagic to keep it in sync with the post. I tried it on chrome 12(latest for Ubuntu) and it works fine.

      • Andro July 15, 2011 at 9:15 AM

        I just download ajaxupload by click the Download button, and make jar file name is “ajaxupload-0.01-SNAPSHOT.jar”. When I using chrome (ver 12 stable) , the first time I upload two files to server, but server get only one uploaded file. And after the first time uploading, I try again to upload two files, this time server could get the correct files. When I use Chrome (Version : 13/14 dev) to upload multiple files , server could get the upload files list , but every file in this list is the same file.
        I also test IE9 and Firefox 5 in this version of ajaxupload, they all work fine. But IE9 can’t use drag and drop file , and only permit selecting one file in one time file selecting dialog window(still can upload multiple files by open selecting file window multiple times).

        I try the latest tawus-ajaxupload (using git to download), make jar file name is “tawus-ajaxupload-0.022-SNAPSHOT.jar” by using gradle. The result is same as above I described. And it get worse in IE9: couldn’t upload file anyway, Firefox5: every first time to upload files, server always get one file, then trying again wil get correct file number.

        Thanks for your reply. Anyway ajaxupload is a great project.

      • Andro July 15, 2011 at 9:38 AM

        I use eclipse to develop my project, I clean project again to test it. I correct the situation “And it get worse in IE9: couldn’t upload file anyway, Firefox5: every first time to upload files, server always get one file, then trying again wil get correct file number.” <– Now it is that "IE9 become to act like chrome, every first time to upload files, server always get one file, then trying again wil get correct file number. The other brower (firefox/chrome) still act like before"

  3. Robert Zeigler July 26, 2011 at 1:27 AM Reply

    Note: the remove link is broken if your page uses an activation context because the js does: this.removeLink + ‘/’ + serverIndex as the actual remove link. This is fine for no activation context, but the removeLink will be: someurl?t:ac=thecontext with an activation context. I wonder if using a request parameter to identify the serverid would be better? It would take a bit more work… alternatively, you could build the link with a “dummy” context and use regex to replace the context client side with the desired value?

  4. Robert Zeigler July 26, 2011 at 5:07 AM Reply

    Hm. Seems to me that there’s a threading issue with the AjaxUploadDecoderImpl if you allow more than one upload at a time. AjaxUploadDecoderImpl isn’t per-thread, and doesn’t store the uploaded file in a per-thread manner. Since the uploader is called 1x per file, each file coming from a different request (so a different thread), you can get:
    process upload 1
    process upload 2
    assign file for upload 1
    assign file for upload 2

    This results in the same uploadedFile being assigned multiple times.

    Ideally, you would want:
    process upload 1
    assign file for upload 1
    process upload 2
    assign file for upload 2

    This will happen if you click the “upload files” button 2x and select one file each time, assuming that the uploads finish at separate times. But if you select the “upload files” button and select multiple files, it’s quite easy to see the race condition.

    You could fix this by making AjaxUploadDecoder service PerThread scope. Alternatively, you could use PerthreadManager to store and fetch the value. Which is what I’ve done locally. I also fixed the issue mentioned above. I can send a patch along if you want.

  5. hongdengdao July 31, 2011 at 7:40 PM Reply

    hi, i found a minor bug and fix it .
    in js file:

    _setSizeField : function()
    {
    if(this._options.sizeField && document.getElementById(this._options.sizeField))
    {
    document.getElementById(this._options.sizeField).value =
    this._listElement.children.length == 0 ? null: this._listElement.children.length;
    }
    },

    sizefield is used by getElementById, but in ajaxupload.java , it gives controlname ,
    and some times controlname not equals to id , so it may cause validate problem, like zone update, cause the value can’t be set.

    fix it by change ajaxupload.java

    spec.put(“sizeField”, getControlName());

    to

    spec.put(“sizeField”, getClientId());

    • tawus July 31, 2011 at 8:52 PM Reply

      Thanks for pointing it out. Updated the source

  6. Jarett February 1, 2012 at 12:43 AM Reply

    I love this plugin and it’s working great for me with a single element. However, is there anyway to put a dynamic amount of AjaxUpload inputs in one form? I’m no tapestry master, so be specific if possible, but I’m having quite a bit of trouble creating a dynamic list of List objects and getting it to work nicely with AjaxUpload….

    Something like:

    @Persist
    private HashMap<Person, List> uploadList;
    @Property
    private Person currentPerson;
    @Property
    private List people;

    public List getUploads() {
    return uploadList.get(currentPerson);
    }
    public void setUploads(List list) {
    uploadList.put(currentPerson, list);
    }
    ————–

    Any other suggestions would be appreciated. I’ve tried creating objects rather than the HashMap of People to Lists, and also just a List of List of UploadedFile objects… Thanks!

  7. sommeralex February 8, 2012 at 8:24 PM Reply

    Hi!

    Could you please provide some more information to use this component from the scratch / for tapestry beginners? Where do we have to download and integrate git source?

    thx
    alex

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

Follow

Get every new post delivered to your Inbox.

Join 91 other followers

%d bloggers like this: