Wednesday, October 17, 2007

Bad way to extend an ASP.NET product - the details

Last week I posted about extending an ASP.NET product without changing the code-behind. You'll need read it to make sense of this post. That was the motivation, now here's the technical details, as promised.

The requirements, and as you read on, keep in mind that I have no access to the code-behinds:

We have a document with an approval workflow. We need to extend this document with attachments. Submitters are allowed to attach files, while approvers may only view the attached files.


To do this, I first put together an ASP.NET 2.0 webform (Upload.aspx) with FileUpload controls in a DetailsView bound to a SqlDataSource. That was the easy part. Upload.aspx is styled to look like the rest of the webapp. (Read this article for a tutorial on how to do this.)

To connect this new upload functionality to our existing application, I load a JavaScript file into all target webforms. This client-side script, customization.js, creates an iframe containing Upload.aspx, and attaches custom events to the existing form submit buttons.

Creating the Iframe
The iframe creator method, in customization.js, using DOM and innerHTML:


// buildContainer() is called on load
function buildContainer(target, id, mode) {
var container = document.createElement("div");

container.innerHTML = [
'<iframe name="uploader" id="uploader" ',
'frameborder="0" scrolling="no" ',
'src="Upload.aspx?',id=',id||'','&mode=',mode||'','"',
'></iframe>'
].join('');

target.appendChild(container);
}

Notice that id and mode parameters are passed through the query string.

If an id parameter is passed, Upload.aspx will assume the document has previously been created. It will set the DetailsView.DefaultMode to Edit, query the database for attachments and binds the UI appropriately. Otherwise, the DetailsView will stay in the default Insert mode.

The mode parameter is used in conjunction with an existing document id to indicate whether the FileUpload controls rendered should allow user editing of the attachments.

After dynamically inserting an iframe into the webform DOM, I added custom events to trigger Upload.aspx to uploaded and link the attachments to the current document.

Adding client-side events to trigger file uploads
On each submitter form, there may be one or more buttons which saves the document e.g. "Save as Draft" and "Save and Submit". We want each of these buttons to also cause the iframed Upload.aspx to upload the attachments.

Here's the code, also in customization.js:


(function(button_ids){

/* FileUploaded is called from Upload.aspx
* after it successfully saves the attachments.
*/
window.FileUploaded = function(goto_id, doc_key) {
document.getElementById('Notes').value+='|'+doc_key;
document.getElementById(goto_id).click();
}


for(var i in button_ids) {
var button_id = button_ids[i];
var button = document.getElementById(button_id);

if(button) {
var newbutton = document.createElement('button');

// copy all existing button attributes
newbutton.innerText = button.value;
newbutton.className = button.className;
newbutton.disabled = button.disabled;

newbutton.executed = false;
newbutton.goto_id = button_id;
newbutton.id = 'proxy_' + button_id;

// newbutton tells Upload.aspx to save
newbutton.onclick =
function() {
if(!this.executed) {
frames.uploader.Save(this.goto_id, newid());
}
return false;
}


// newbutton added, and old button is hidden
button.parentNode.insertBefore(newbutton, button);
button.style.display = 'none';

} // end if(button)
} // end for all buttons

})(['Button1','Button2']);

Briefly, an anonymous function is run on page load, called with an array of button ids. After I cloned these buttons - cloneNode() did not work properly for me, so I created the buttons from scratch - I hid them from the user and added the clones to the UI.

I attached events to the clones' onclick handlers, which would, when clicked, call the client-side Save() method in Upload.aspx. The Save() method:


function Save(goto_id, document_key) {
document.getElementById('GotoIdTextBox').value = goto_id;
document.getElementById('DocKeyTextBox').value = document_key;
document.getElementById('SubmitButton').click();
}

The goto_id passed is the id of the "real" button, which is the origin of clone. The document_key is a random script generated key to couple the document and its uploads.

Tying the document with its attachments
After Upload.aspx saves the attachments, it will call the containing frame's FileUploaded() method, with the id of the triggering clone button's source button. In Upload.aspx's script block:


/* UploadDataSource_Updated() outputs
* the same client-side script
*/
private void UploadDataSource_Inserted(
Object sender,
SqlDataSourceStatusEventArgs e)
{
this.Controls.Add(
new LiteralControl(
"<script>" +
"parent.FileUploaded(" +
"'"+GotoIdTextBox.Text+"'," +
"'"+DocKeyTextBox.Text+"');" +
"</script>")
);
}


The line document.getElementById('Notes').value+='|'+doc_key; in FileUploaded() hijacks the Notes field and appends the script-generated key. Thus, the creation of an artificial data link.


When in edit or viewing mode, the id will be extracted from the Notes TextBox on page load and truncated. So, the user does not see this random string at all.

Conclusion
Looks and feels wrong, doesn't it? Well, we're rolling this out to our customers soon. If only the webapp had inline-scripted webforms, I wouldn't have gone this way.

Again, and I know you guys don't like to comment, but how would you have done things differently?

0 comments: