Emitting Cancelable / Preventable Output Events In Angular 2 RC 3
In the one-way data flow philosophy embraced by Angular 2, you don't often have to cancel a component's output event directly; if a component emits an output event that you want to cancel, just don't pipe the event data back into the target component. But, I don't think it's wise to try and reduce every component down to a set of input bindings. Of course, this means that we'll sometimes need a way to cancel output events that don't correspond directly to input bindings. In those cases, I wanted to experiment with emitting actual Event objects, instead of event data, that can be canceled by the calling context.
Run this demo in my JavaScript Demos project on GitHub.
A specific scenario that comes to mind is that of an upload widget. An upload widget might have many states and maintain an internal queue of files and have a lot of logic around how files are organized, filtered, and prepared for transport. To try and shoehorn that entire ecosystem into a set of input binding - controlled by a uni-directional flow of data - may be way more trouble than it's actually worth.
That said, there will likely be parts of the upload workflow over which the calling context might need to exercise control. In those cases, I think we can borrow from the browser DOM (Document Object Model) and have the upload component emit cancelable events. Then, if the calling context needs to cancel the operation, it can call something like .preventDefault() on the emitted event value.
To explore this idea, I created an overly-simple proof-of-concept (POC) for a file-upload component. In this POC, when the user drops files onto the uploader, the uploader emits a "fileAdd" event for each file. This "fileAdd" event can be monitored by the calling context. And, if one of the given files shouldn't be added to the upload queue (as per some business logic that only the calling context knows about), the calling context can call .preventDefault() on the emitted event.
First, let's look at the root component, which will consume the file-upload component:
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { FileUploaderComponent } from "./file-uploader.component";
import { FileAddEvent } from "./file-add-event";
@Component({
selector: "my-app",
directives: [ FileUploaderComponent ],
template:
`
<p>
You can drag-n-drop files onto the hit target.
( <strong>PNG files not allowed</strong> )
</p>
<file-uploader
(fileAdd)="handleFileAdd( $event )">
</file-uploader>
`
})
export class AppComponent {
// I handle the fileAdd event on the uploader and determine if the given file
// can be added to the upload queue. The default behavior is that all files will
// be allowed unless explicitly prevented.
public handleFileAdd( event: FileAddEvent ) : void {
var isPngFile = /\.(png)$/i.test( event.file.name );
// If the dropped file is a PNG, prevent it from being added to the uploader.
// Since the uploader isn't controlled by a one-way data flow of files, we do
// this by preventing the default behavior on the given event object.
isPngFile && event.preventDefault();
}
}
Here, you can see that the root component (ie, the calling context) is binding to the (fileAdd) event emitted by the FileUploaderComponent. Then, when the uploader emits the fileAdd event, the root component inspects the name of the selected file. If the file has a PNG file-extension, the root component tells the uploader to omit the file by calling .preventDefault() on the fileAdd event object.
Now, let's look at how the FileUploaderComponent generates, emits, and checks the state of the event object. The most relevant part of this component is the handleDrop() method:
// Import the core angular services.
import { Component } from "@angular/core";
import { EventEmitter } from "@angular/core";
// Import the application components and services.
import { FileAddEvent } from "./file-add-event";
@Component({
selector: "file-uploader",
outputs: [ "fileAdd" ],
host: {
"(dragover)": "handleDragover( $event )",
"(dragleave)": "handleDragleave( $event )",
"(drop)": "handleDrop( $event )",
"[class.for-hover]": "isActivated"
},
template:
`
<div class="instructions">
Drop Files
</div>
`
})
export class FileUploaderComponent {
// I am the output event stream for fileAdd events.
public fileAdd: EventEmitter<FileAddEvent>;
// I determine if the dropzone is visibly activated.
public isActivated: boolean;
// I hold the timer that helps normalize the drag leave and over events.
private dragleaveTimer: number;
// I initialize the component.
constructor() {
// When setting up the fileAdd output event, we are setting this to be a
// SYNCHRONOUS EventEmitter. We have to do this because the event object that
// we're emitting can be mutated by the calling context (ie, default behavior
// canceled) and our internal event mechanism needs to be able to synchronously
// check for those mutations.
// --
// NOTE: Async used to be the default; in Angular 2 RC 3, however, the default
// is to be synchronous. That said, I'm leaving this in here to drive home the
// point that it is a critical aspect of this interaction.
this.fileAdd = new EventEmitter( /* isAsync = */ false );
this.isActivated = false;
this.dragleaveTimer = 0;
}
// ---
// PUBLIC METHODS.
// ---
// I handle the dragleave event on the host.
public handleDragleave( event: DragEvent ) : void {
console.debug( "dragleave" );
// Since the drag events in the browser are a special kind of nightmare, the
// dragleave event fires in places where we would not expect it to. As such,
// we need to use a timer to see if the dragover event will fire shortly after
// this event has fired.
this.dragleaveTimer = setTimeout(
() => {
this.isActivated = false;
},
50
);
}
// I handle the dragover event on the host.
public handleDragover( event: DragEvent ) : void {
console.debug( "dragover" );
// We need to prevent the default behavior so that the drop event doesn't
// break the page.
event.preventDefault();
clearTimeout( this.dragleaveTimer );
this.isActivated = true;
}
// I handle the drop event on the host. Each file contained in the drop event will
// be emitted to the calling context as a FileAddEvent where the calling context
// has an opportunity to cancel the file operation.
public handleDrop( event: DragEvent ) : void {
console.debug( "drop" );
// We need to prevent the default behavior so that the drop event doesn't
// break the page.
event.preventDefault();
// Each of the dropped files can be added to the upload queue; however, we want
// to give the calling context an opportunity to prevent that from happening. As
// such, we're going to emit an output event for each file and given the calling
// context an opportunity to cancel the file operation.
for ( var file of event.dataTransfer.files ) {
var fileAddEvent = new FileAddEvent( file );
// Emit the output event. Since this EventEmitter is SYNCHRONOUS, we will be
// able to check the event state to see if it was changed by the event
// handler in the calling context.
this.fileAdd.emit( fileAddEvent );
// If the calling context has prevent the file operation, don't add it to the
// internal queue (not part of the demo, obviously).
if ( fileAddEvent.isDefaultPrevented() ) {
console.warn( "FileAdd [", file.name, "] being prevented." );
} else {
console.info( "FileAdd [", file.name, "] being permitted to continue." );
// NOTE: Obviously, the upload queue is not part of the demo ....
}
}
this.isActivated = false;
}
}
As you can see, when the files are dropped on the uploader, the uploader loops over each file; and, for each one, it creates a new FileAddEvent instance and emits it. After the event is emitted, the uploader then inspects the FileAddEvent object to see if the calling context prevented the default behavior. And, if so, it omits the given file from the uploader queue (which is not part of the demo).
As of Angular 2 RC 1 (I think), the EventEmitter object is synchronous by default. But, I am leaving the EventEmitter constructor in the code in order to underscore the fact that this synchronous behavior is critical. If the EventEmitter emitted asynchronous events, the calling context wouldn't have time to interact with the event object before the uploader continued on with the upload workflow. However, since we are using a synchronous EventEmitter, it means that the calling context will have access to the event object in the midst of the upload workflow. And, as such, it will have an opportunity to alter the event object in a way that the uploader will be able to perceive.
The FileAddEvent object itself is super simple - it's basically one private property with an accessor and a mutator for the default behavior.
export class FileAddEvent {
// I hold the file that was added to the uploader.
public file: File;
// I determine if the default behavior has been prevented.
private _isDefaultPrevented: boolean;
// I initialize the event object.
constructor( file: File ) {
this.file = file;
}
// ---
// PUBLIC METHODS.
// ---
// I determine if the default behavior has been prevented.
public isDefaultPrevented() : boolean {
return( this._isDefaultPrevented );
}
// I prevent the default behavior for this event.
public preventDefault() : void {
this._isDefaultPrevented = true;
}
}
Now, if we were to run this code and drop a mixture of JPG and PNG files on the uploader, we can see that the root component is able to blog the PNG files from being processed:
As you can see, while the file-uploader isn't being driven by its inputs, the emitted event is still allowing the root component to retain control of the general upload process.
When it comes to a uni-direction data flow, I think the biggest wins come from situations in which a component can be completely stateless. Meaning, it's rendering is driven entirely by its inputs. But, with something like an uploader, it is necessarily stateful as it has to deal with the transmission of files which is a stateful process. As such, it cannot [easily] be controlled by inputs alone. And, in cases like that, I think that emitting cancelable events, in Angular 2, bridges the gap by keeping the calling context involved in the control flow of component state change.
Want to use code from this post? Check out the license.
Reader Comments