Introduction
Here’s the overall flow:
- Add some placeholder HTML to act as our drop zone
- Listen for drop events on the drop zone
- Loop over each ‘dropped’ file, getting its contents and metadata
- Upload each file as a note attachment regarding the case, using the portal web API
- Generate a thumbnail of each uploaded file
For the impatient among you, here’s the final code for you to steal / test drive 🙂
We’ll create all this functionality on a standalone page for now. In a following post, I’ll show you how to integrate this functionality into a multistep form
Stage 1 – Create a ‘drop zone’
Snippets
Boilerplate code for a ‘drop zone’
There isn’t a specific HTML element for a drop zone. Here we’ve got a div which we’ll make ‘drag & drop-able’ using JavaScript. Also, we’ll want somewhere to list / preview any files that have been uploaded – so I’ve included a list of files with a few placeholders to begin with
<!-- Div to act as a container for drag and rop events as well as any existing attachments --> <div id="drop-zone"> <p>Drag and drop files here</p> <!-- List to hold the preview thumbnails of any attached files --> <ul id="file-list"> <li class="attachment-preview"><div data-id="put-note-guid-here" data-filename="Example filename.jpg"><img class="img-responsive" src="https://picsum.photos/300/300"></div></li> <li class="attachment-preview"><div data-id="put-note-guid-here" data-filename="Example filename.pdf"><div class="inner"><span class="glyphicon glyphicon-file" aria-hidden="true"></span> Example filename.pdf</div></div></li> </ul> </div>
Drop zone styling
By default, the drop zone won’t look any different whether or not the user is interacting / dragging files. Here’s some example CSS to style the drop zone differently on dragover vs dragleave and drop. Note – it’s the JavaScript that will add / remove the classes that make these styles take effect
I’ve also included some styles for the file list…
#drop-zone { width: 100%; min-height: 200px; border: 5px dashed #7e878d; text-align: center; background-color: #c2cfd730; border-radius: 0.5em; padding: 1em; overflow-y: hidden; } #drop-zone.dragover { background: #c2cfd7; transition: background ease 0.3s; } .attachment-preview { width: 200px; height: 200px; float: left; border: 5px solid #adb6d2; margin: 0 1em 1em 0; border-radius: 0.5em; box-shadow: 0 2px 5px rgba(0,0,0,0.3); display: flex; justify-content: center; padding: 1em; } .inner { display: flex; align-items: center; overflow-wrap: anywhere; font-size: clamp(0.5em, 1em, 1em); } .attachment-preview .glyphicon { font-size: 3em; display: block; margin-bottom: 0.25em; }
Stage 2- The basics of drag & drop events
So far we have an area of the page styled to look like drag and drop but doesn’t actually DO anything yet
Our mission for this stage is…
- Respond to dragover, dragleave, drop
- Provide some visual feedback by adding or removing a class (which we have styles for in the CSS)
- Prevent that default behaviour of just opening the file in a new browser tab
Key skills covered
- Listening for events with jQuery
- Adding & removing classes to provide visual feedback (forcing different CSS rules to apply for dragover vs dragleave and drop)
- Preventing the browser’s default behaviour for a specific event
Here are the key events we care about:
Event | Description |
---|---|
dragover | The user’s cursor is over the drop zone with one or more files selected |
dragleave | The user’s cursor has left the drop zone without dropping / releasing the files |
drop | One or more files have been released / ‘dropped’ in the drop zone – this our trigger for upload |
Snippets
$(document).ready(function() { // Initialise a variable that references the "drop-zone" container defined in the HTML var dropZone = $('#drop-zone'); // Add a dragover event listener to the dropZone dropZone.on('dragover', function() { // Add a class of 'dragover'. We'll use this to provide visual feedback $(this).addClass('dragover'); return false; }); // Add a dragleave event listener to the dropZone dropZone.on('dragleave', function() { // Remove the'dragover' class. We'll use this to provide visual feedback $(this).removeClass('dragover'); return false; }); // Add a drop event listener to the dropZone. This is our key event dropZone.on('drop', function(e) { // Prevent the default behavior of opening the file in the browser e.preventDefault(); // Remove the'dragover' class. We'll use this to provide visual feedback $(this).removeClass('dragover'); }); });
Useful links
Stage 3 – File content and metadata
In this stage we’ll get our hands dirty with reading files and accessing their contents. The aim is to get the base64 contents of the files plus the name and mimetype in preparation for uploading in the next stage
Key skills covered
- Accessing files from the File Drag & Drop API
- Looping over the files with jQuery
- Using FileReader and triggering functionality onload of a file
Snippets
// Retrieve the dropped files from the event object using e.originalEvent.dataTransfer.files var files = e.originalEvent.dataTransfer.files; // Loop through the files array $.each(files, function(index, file) { // Initialise a new file reader var reader = new FileReader(); // What to do once the reader.onload = function(event) { // Split the file contents after the first comma (removing the base64 prefix) var base64 = event.target.result.split(',')[1]; var fileName = file.name; var fileType = file.type; var fileSize = file.size; // Run the uploadToNoteAttachment function, passing the base64 content, the file name and the file type uploadToNoteAttachment(base64,fileName,fileType); }; // Read the file reader.readAsDataURL(file); });
Useful links
Stage 4 – Upload dropped files using the Portal Web API
We’ll now take that file content and metadata and upload using the Portal Web API. Not confident writing API calls from scratch? No worries… Guido’s excellent Dataverse REST Builder tool has us covered 😎
Snippets
function uploadToNoteAttachment(base64,fileName,fileType) { var record = {}; record.filename = fileName; // Text record.documentbody = base64; // Text record.subject = fileName; // Text // Set the Regarding field to a case with GUID taken from the id parameter in the URL record["objectid_incident@odata.bind"] = "/incidents({{ request.params.id }})"; // Lookup webapi.safeAjax({ type: "POST", contentType: "application/json", url: "/_api/annotations", data: JSON.stringify(record), success: function (data, textStatus, xhr) { var newId = xhr.getResponseHeader("entityid"); console.log(newId); }, error: function (xhr, textStatus, errorThrown) { console.log(xhr); } }); }
Useful links
- Wrapper AJAX – Letting the Portal Web API know this call is from a legit source
- The Dataverse REST Builder extension for Chromium browsers
Stage 5 – Generate preview thumbnails
We’re already successfully uploading files as note attachments regarding the case – but how do our customers know? In this stage we’ll see generate thumbnail images / icons and populate our file list. We’re so close! 👏
Key skills covered
- The ‘special’ URL to retrieve note attachments: /_entity/annotation/putyourguidhere
- JavaScript if statement to check the mime type and provide different output
- Appending to the DOM using jQuery
Snippets
function generateThumbnail(guid,fileName,mimeType) { if(mimeType.startsWith('image')) { var thumbnail = '<li class="attachment-preview"><div data-id="' + guid + '" data-filename="' + fileName + '"><img class="img-responsive" src="/_entity/annotation/' + guid + '"></div></li>'; } else { var thumbnail = '<li class="attachment-preview"><div data-id="' + guid + '" data-filename="' + fileName + '"><div class="inner"><span class="glyphicon glyphicon-file" aria-hidden="true"></span> ' + fileName + '</div></div></li>'; } // Append the thumbnail to the file list $('ul#file-list').append(thumbnail); }
Useful links
Stage 6 – Load existing attachments when the page first loads
So all good for freshly uploaded files and images. What if the user returns to this page or refreshes though? It’ll look like there’s nothing attached. Let’s now see how we can load any existing attachments using jQuery’s document ready event
Snippets
function getExistingAttachments() { webapi.safeAjax({ type: "GET", url: "/_api/annotations?$select=annotationid,filename,mimetype&$filter=(_objectid_value eq {{ request.params.id }} and isdocument eq true)&$orderby=createdon desc", contentType: "application/json", success: function (data, textStatus, xhr) { var results = data; console.log(results); for (var i = 0; i < results.value.length; i++) { var result = results.value[i]; // Columns var annotationid = result["annotationid"]; // Guid var filename = result["filename"]; // Text var mimetype = result["mimetype"]; // Text // Run the generateThumbnail function, passing the note GUID, file name and mimetype as inputs generateThumbnail(annotationid,filename,mimetype); } }, error: function (xhr, textStatus, errorThrown) { console.log(xhr); } }); }
The complete solution
The final HTML & Liquid
{% include 'Portal Web API Wrapper' %} <!-- Div to act as a container for drag and drop events as well as any existing attachments --> <div id="drop-zone"> <p>Drag and drop files here</p> <!-- List to hold the preview thumbnails of any attached files --> <ul id="file-list"></ul> </div>
The final JS
// When the web page / UI is ready $(document).ready(function() { getExistingAttachments(); // Initialise a variable that references the "drop-zone" container defined in the HTML var dropZone = $('#drop-zone'); // Add a dragover event listener to the dropZone dropZone.on('dragover', function() { // Add a class of 'dragover'. We'll use this to provide visual feedback $(this).addClass('dragover'); return false; }); // Add a dragleave event listener to the dropZone dropZone.on('dragleave', function() { // Remove the'dragover' class. We'll use this to provide visual feedback $(this).removeClass('dragover'); return false; }); // Add a drop event listener to the dropZone. This is our key event dropZone.on('drop', function(e) { // Prevent the default behavior of opening the file in the browser e.preventDefault(); // Remove the'dragover' class. We'll use this to provide visual feedback $(this).removeClass('dragover'); // Retrieve the dropped files from the event object using e.originalEvent.dataTransfer.files var files = e.originalEvent.dataTransfer.files; // Loop through the files array $.each(files, function(index, file) { // Initialise a new file reader var reader = new FileReader(); // What to do once the reader.onload = function(event) { // Split the file contents after the first comma (removing the base64 prefix) var base64 = event.target.result.split(',')[1]; var fileName = file.name; var fileType = file.type; var fileSize = file.size; // Run the uploadToNoteAttachment function, passing the base64 content, the file name and the file type uploadToNoteAttachment(base64,fileName,fileType); }; // Read the file reader.readAsDataURL(file); }); }); }); // Function to upload the note attachment using the Portal Web API // Accepts the file content, file name and file type as parameters function uploadToNoteAttachment(base64,fileName,fileType) { var record = {}; record.filename = fileName; // Text record.documentbody = base64; // Text record.subject = fileName; // Text // Set the Regarding field to a case with GUID taken from the id parameter in the URL record["objectid_incident@odata.bind"] = "/incidents({{ request.params.id }})"; // Lookup webapi.safeAjax({ type: "POST", contentType: "application/json", url: "/_api/annotations", data: JSON.stringify(record), success: function (data, textStatus, xhr) { var newId = xhr.getResponseHeader("entityid"); console.log(newId); // On successful upload, run the generateThumbnail function, passing the GUID of the newly created note record, the file name and the file type generateThumbnail(newId,fileName,fileType); }, error: function (xhr, textStatus, errorThrown) { console.log(xhr); } }); } // Function to generate thumbnails with either an preview for images or a file icon for other file types // Accepts a ntoe GUID, a file name and mime type as parameters function generateThumbnail(guid,fileName,mimeType) { if(mimeType.startsWith('image')) { var thumbnail = '<li class="attachment-preview"><div data-id="' + guid + '" data-filename="' + fileName + '"><img class="img-responsive" src="/_entity/annotation/' + guid + '"></div></li>'; } else { var thumbnail = '<li class="attachment-preview"><div data-id="' + guid + '" data-filename="' + fileName + '"><div class="inner"><span class="glyphicon glyphicon-file" aria-hidden="true"></span> ' + fileName + '</div></div></li>'; } // Append the thumbnail to the file list $('ul#file-list').append(thumbnail); } // Function to retrieve any existing attachments regarding the current case (using the GUID from the URL) function getExistingAttachments() { webapi.safeAjax({ type: "GET", url: "/_api/annotations?$select=annotationid,filename,mimetype&$filter=(_objectid_value eq {{ request.params.id }} and isdocument eq true)&$orderby=createdon desc", contentType: "application/json", success: function (data, textStatus, xhr) { var results = data; console.log(results); for (var i = 0; i < results.value.length; i++) { var result = results.value[i]; // Columns var annotationid = result["annotationid"]; // Guid var filename = result["filename"]; // Text var mimetype = result["mimetype"]; // Text // Run the generateThumbnail function, passing the note GUID, file name and mimetype as inputs generateThumbnail(annotationid,filename,mimetype); } }, error: function (xhr, textStatus, errorThrown) { console.log(xhr); } }); }
The final CSS
#drop-zone { width: 100%; min-height: 200px; border: 5px dashed #7e878d; text-align: center; background-color: #c2cfd730; border-radius: 0.5em; padding: 1em; overflow-y: hidden; } #drop-zone.dragover { background: #c2cfd7; transition: background ease 0.3s; } .attachment-preview { width: 200px; height: 200px; float: left; border: 5px solid #adb6d2; margin: 0 1em 1em 0; border-radius: 0.5em; box-shadow: 0 2px 5px rgba(0,0,0,0.3); display: flex; justify-content: center; padding: 1em; } .inner { display: flex; align-items: center; overflow-wrap: anywhere; font-size: clamp(0.5em, 1em, 1em); } .attachment-preview .glyphicon { font-size: 3em; display: block; margin-bottom: 0.25em; }
Would it be possible to add a delete button to each thumbnail to allow documents that have been uploaded in error to be deleted? Or some other straightforward way for users to trim the list of documents attached to the record?
Great question. Yes definitely possible. Coincidentally enough, I’ve just implemented this for a client. Follow-up post coming soon 🙂