Ajax Upload for Tapestry
Join the DZone community and get the full member experience.
Join For FreeTapestry 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.
- Once an attachment is uploaded, there is no way to remove that attachment.
- If the form is to be resubmitted, the previous uploaded files should be displayed as well
- 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 :-
- element: Element which will be used for creating the component
- 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
- cancelLink: URL which will be called when upload is cancelled
- removeLink: URL which will be called when an uploaded file is to be removed
- 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
- sizeLimit: Maximum size of file allowed to be uploaded
- name: Name of the file field created
- uploadText: Text to be displayed on the upload button
- 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(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>
and in the class file
@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
From http://tawus.wordpress.com/2011/06/25/ajax-upload-for-tapestry/
Opinions expressed by DZone contributors are their own.
Comments