This example continues that work to show how easily you could have multiple “detail” forms set to one main data model, using a presentation model in the “detail” forms, and the Parsley Framework of course.
Although this example shows two “detail” forms simultaneously, it isn't a classical MDI application because it doesn't actually allow you to create any number of forms. It sticks to two forms. That is because creating and MDI interface was beyond the scope of this example, this example focuses on having multiple “detail” forms, each with its own presentation model, communicating between each other and to a “master” presentation model. The example show here although set to two “detail” forms, it will work with any number of “detail” forms.
If you have not gone through the “Presentation Model implemented in Parsley” example, I strongly encourage you to do so first, as I will not go into the details of the Presentation model in this article. It will focus primarily on the few changes made from the previous example.
As a refresher, let’s take a look at the class diagram.
Presentation Model Class Diagram using Parsley and Flex |
This has not changed, it was originally set up so this would be easy to adapt.
On to the example
The interface has changed a bit from the previous example. On the first example there was a list that lets the user select which album he/she could edit. From the users perspective, the list and the album detail form were tightly bound, but from the code perspective they are loosely bound.
In this example, the user is no longer limited to editing one album at a time, but rather than having two separate lists, there is still only one list. The user can select to what album detail form it wishes to bind the list selection. This shows one of the benefits of having loosely bound code; it is easier to add more features.
So here is the example running with View Source enabled.
As you can see, there is a form that is considered “Active” this is the form that is currently bound to the list. The user can set a form to be active by either clicking on the checkmark or by setting focus on any of the items. You could select the active form in any other way; I just chose to use those two methods.
Knowing which form is active is basically the largest change/addition to this project. Because I really want to have the capability of having unlimited AlbumForms working together with the one list, and I want to keep the list and the forms loosely bound together, I set up a messaging protocol in which the AlbumForm knows if it’s the active one or not. The list does not have to worry about who is the active one, just the form itself.
Ok, let’s take a look at the project structure itself.
The only new class is the ActiveAlbumFormChangeMessage class which is used to communicate which is the active AlbumForm. Also new are the two icons, active.png and inactive.png which are used by the AlbumForm.
Both models, the AlbumBrowserModel and the AlbumFormModel where updated where most of the updates where in the AlbumFormModel.
The AlbumBrowserView was updated only to expand in size to accommodate the additional AlbumFormView. The AlbumFormView was updated only to reflect the Active/Inactive border and Icon, and to call the focus handler when any of its components received focus.
The PresentationModelMultiFormMain which is the main file, was updated only in regards to the size to accommodate the extra AlbumFormView.
Unlike other posts, I will not post 100% of the code. I will post only of the modules that where updated. You can see the rest of the code in the previous example or in the view source of this example.
Ok, let’s look at the code.
First our newcomer, the ActiveAlbumFormChangeMessage.as:
package com.adobe.ac.messages { import com.adobe.ac.model.AlbumFormModel; import com.adobe.ac.vo.AlbumVO; /************************************************* * This message is dispatched when a AlbumForm receives * Focus. It is used to notify all other AlbumForms that it has * focus so that the other forms are aware of this. */ public class ActiveAlbumFormChangeMessage { private var _sender:AlbumFormModel; private var _currentAlbum:AlbumVO; public function ActiveAlbumFormChangeMessage(sender:AlbumFormModel,currentAlbum:AlbumVO) { _sender = sender; _currentAlbum = currentAlbum; } /************************************ * Who actually is sending this message. Usefull to know in case * the receiving part wants to know if it was itself that sent the * message. */ public function get sender():AlbumFormModel { return _sender; } /************************************ * The album that is currently beeing viewed. */ public function get currentAlbum():AlbumVO { return _currentAlbum; } } }
This is a new class that serves the goal to inform other forms that a particular form (the sender) has become the active form. It includes the reference to the object sending the message and the actual album that it is viewing. The active album will be used by the browser model.
Ok, next is AlbumFormModel.as:
package com.adobe.ac.model { import com.adobe.ac.messages.ActiveAlbumFormChangeMessage; import com.adobe.ac.messages.SelectedAlbumChangeMessage; import com.adobe.ac.vo.AlbumVO; import flash.events.FocusEvent; import mx.controls.Alert; import mx.events.CloseEvent; import org.spicefactory.parsley.core.messaging.MessageProcessor; /************************** * This is the presentation Model for the AlbumBrowser's AlbumFormView. * It encapsulates the behaviour and state of the AlbumFormView. * This component instantiates and collaborates with an instance of the * AlbumForm class. * * This is comes from Martin Fowler's post about Presentation Model, which * was then ported to Flex by Paul Williams. I adapted it to show the * same example implemented usign the Parsley Framework * * Martin Fowler http://martinfowler.com/eaaDev/PresentationModel.html * Paul Williams http://blogs.adobe.com/paulw/archives/2007/10/presentation_pa_3.html * Arturo Alvarado http://artinflex.blogspot.com/ */ [Bindable] public class AlbumFormModel { private static const ARTIST_ERROR : String = "Please enter an artist"; private static const TITLE_ERROR : String = "Please enter an album title"; private static const COMPOSER_ERROR : String = "Please enter a composer"; /**************** * Indicates if the album can be edited. Mostly depends on if there * is an album assigned or not */ public var canEdit : Boolean = false; /***************** * Indicates if the composer can be edited. Mostly depends on if it's * set to classical or not. */ public var canEditComposer : Boolean = false; public var artistError : String = ""; public var titleError : String = ""; public var composerError : String = ""; /****************** * Indicates if the Cancel button should be enabled or not. Mostly depends * on if there has been changes to the fields of the album or not. */ public var canCancel : Boolean = false; /****************** * Indicates if the Apply button should be enabled or not. Mostly depends * on if there has been changes to the values or not, and if the values * entered are valid. */ public var canApply : Boolean = false; private var artistValid : Boolean = false; private var titleValid : Boolean = false; private var composerValid : Boolean = false; /*************** * Current values of the album. It's a copy of the original so that * the changes can be reverted. Undo/Cancel */ private var _album : AlbumVO; /**************** * A reference to the original Album data to be able to updated it when * the apply button is pressed. */ private var _albumToUpdate : AlbumVO; /***************** * The current value of the album being worked on. */ public function get album() : AlbumVO { return _album; } public function set album( album : AlbumVO ) : void { artistError = ""; titleError = ""; composerError = ""; if ( album != null ) { _album = album.clone(); _albumToUpdate = album; canEdit = true; canEditComposer = _album.isClassical; albumUpdated(); } else { _album = null; _albumToUpdate = null; canEdit = false; canEditComposer = false; } } /******************************** * This value indicates if this album form has the focus regarding the album * selection. It is somewhat tied to the focus of the form, but not entirely * because it doesn't loose it's active if the form looses focus. It only * looses it's active property when another form becomes active. */ private var _isAlbumSelectionActive:Boolean = false; public function get isAlbumSelectionActive():Boolean { return _isAlbumSelectionActive; } public function set isAlbumSelectionActive(value:Boolean):void { _isAlbumSelectionActive = value; } /****************** * Message dispatcher used to notify that the current album * selected has changed */ [MessageDispatcher] public var messageDispatcher:Function; /////////////////////////////////////////////////// // Methods ///////////// /******************************** * This is the message interceptor of the AlbumChangedMessage that is dispatched * from the AlbumBrowserModel when the user clicks on a different album from the * list. * It does several things. It first checks to see if the current album being * worked on has any changes pending. If it doesn't, it authorizes the message * to continue. * If the current album has changes pending to be applied, it prompts the user * if he/she is willing to loose the changes. Depending on the answer provided * it updates the message accordingly and proceeds to authorize the message to * continue. */ [MessageInterceptor(type="com.adobe.ac.messages.SelectedAlbumChangeMessage")] public function interceptSelectAlbumChange (processor:MessageProcessor) : void { var listener:Function = function (event:CloseEvent) : void { var message:SelectedAlbumChangeMessage = processor.message as SelectedAlbumChangeMessage; if ( event == null || event.detail == Alert.YES ) message.cancelled = false; else message.cancelled = true; processor.proceed(); }; var message:SelectedAlbumChangeMessage = processor.message as SelectedAlbumChangeMessage; if (isAlbumSelectionActive && changesToSave()) { Alert.show("Do you want to abandon your changes?", "Album Browser", Alert.YES | Alert.NO, null, listener); } else { processor.proceed(); } } /************************ * Receives the selectedAlbumChangeMessage dispatched by changeSelectedAlbum. * It receives the message After the Message Interceptor has handled the message. * If the message has not been canceled it goes ahead and changes the selected * album. */ [MessageHandler] public function handleAlbumChangeMessage(selectedAlbumChangeMessage:SelectedAlbumChangeMessage):void { if (isAlbumSelectionActive && !selectedAlbumChangeMessage.cancelled) { album = selectedAlbumChangeMessage.selectedAlbum; } } /************************ * Receives a message indicating that some other form has become active * It checks to see if the form sending the message is itself, and if it * isn't it proceeds to set it's flag to false, knowing that it is no longer * the active AlbumForm. */ [MessageHandler] public function handleActiveAlbumFormChangeMessage(activeAlbumFormChangeMessage:ActiveAlbumFormChangeMessage):void { if (activeAlbumFormChangeMessage.sender != this) { isAlbumSelectionActive = false; } } /******************************************* * Function that is called when the form becomes active. * It checks to see if it is already labeled as active. If it's not * then it marks itself as active and dispatches a messages indicating * that it is the active one for other forms to know. */ public function formFocusChangeHandler():void { if (!isAlbumSelectionActive) { isAlbumSelectionActive = true; if (messageDispatcher != null) messageDispatcher(new ActiveAlbumFormChangeMessage(this,_albumToUpdate)); } } /***************************************** * Called when the artist field is updated an proceeds to validate the data. */ public function updateArtist( artist : String ) : void { album.artist = artist; albumUpdated(); artistError = setErrorString( artistValid, ARTIST_ERROR ); } /***************************************** * Called when the title field is updated an proceeds to validate the data. */ public function updateTitle( title : String ) : void { album.title = title; albumUpdated(); titleError = setErrorString( titleValid, TITLE_ERROR ); } /***************************************** * Called when the isClasical checkbox is updated an proceeds to validate the data. */ public function updateIsClassical( isClassical : Boolean ) : void { album.isClassical = isClassical; canEditComposer = album.isClassical; if ( isClassical == false ) { album.composer = ""; composerValid = true; composerError = ""; } albumUpdated(); } /***************************************** * Called when the composer field is updated an proceeds to validate the data. */ public function updateComposer( composer : String ) : void { album.composer = composer; albumUpdated(); composerError = setErrorString( composerValid, COMPOSER_ERROR ); } /************************ * Called when any field is updated, and proceeds to validate the data */ private function albumUpdated() : void { validateAlbum(); } /************************* * Performes validation of the data, and sets the flags accordingly. */ private function validateAlbum() : void { artistValid = album.artist != ""; titleValid = album.title != ""; composerValid = ( album.isClassical == false ) || ( album.isClassical && album.composer != "" ); canCancel = !album.equals( _albumToUpdate ); canApply = canCancel && artistValid && titleValid && composerValid; } /**************************** * Sets the error string for the hint if the field has invalid data */ private function setErrorString( valid : Boolean, errorString : String ) : String { if ( valid == false ) return errorString; else return null; } /****************************** * Updates the original album data to save the changes. */ public function applyChanges() : void { _albumToUpdate.artist = _album.artist; _albumToUpdate.title = _album.title; _albumToUpdate.composer = _album.composer; _albumToUpdate.isClassical = _album.isClassical; albumUpdated(); } /******************************** * Reverts the changes. Copies the original data back again. */ public function cancelChanges() : void { album = _albumToUpdate.clone(); } /********************************* * Indicates if there are changes pending to be applied. */ public function changesToSave() : Boolean { return canCancel; } } }
This one has a couple of changes and some new stuff. First off, I fixed an issue brought up in one of the comments on the previous example. The comment suggested that it could be potentially troublesome in the future if the interceptor did more than just set the cancel flag. That was correct, so I separated the handling the message and the interceptor for the SelectedAlbumChangeMessage. There is a new function called handleAlbumChangeMessage. It's a simple change, but could save hours of debugging in the future if the project grew. This is because all interceptors are executed before handlers, but there is no guarantee which interceptor will be called first. In this example it doesn't make a difference but it is good practice to have them separate.
New here is the handler for the ActiveAlbumFormChangeMessage. This basically sets a flag that this form is no longer the active form. The View has a couple of things bound to the isAlbumSelectionActive property. It has a border and an Icon that reflect if this form is active or not. Also note that the MessageInterceptor also was changed to act depending if the form is active or not. That is what makes it look like it is bound or not to the list.
The view form also has focus handlers that call the formFocusChangeHandler() function which dispatches the ActiveAlbumFormChangeMessage message.
Ok, so let's take a look at the view. Here is AlbumFormView.mxml:
<?xml version="1.0" encoding="utf-8"?> <!--- This is the AlbumBrowser AlbumFormView. It contains little logic. The login is implemented in the Presentation model that is injected into this view. This is comes from Martin Fowler's post about Presentation Model, which was then ported to Flex by Paul Williams. I adapted it to show the same example implemented usign the Parsley Framework Martin Fowler http://martinfowler.com/eaaDev/PresentationModel.html Paul Williams http://blogs.adobe.com/paulw/archives/2007/10/presentation_pa_3.html Arturo Alvarado http://artinflex.blogspot.com/ --> <mx:Form xmlns:mx="http://www.adobe.com/2006/mxml" xmlns:view="com.adobe.ac.view.*" horizontalScrollPolicy="off" verticalScrollPolicy="off" xmlns:parsley="http://www.spicefactory.org/parsley" borderColor="#0992F1" borderStyle="{(albumFormModel.isAlbumSelectionActive)?'solid':'none'}" borderThickness="3"> <mx:Script> <![CDATA[ import com.adobe.ac.model.AlbumFormModel; [Bindable] public var albumFormModel : AlbumFormModel; private function handleInjectionsComplete():void { albumFormModel.formFocusChangeHandler(); } ]]> </mx:Script> <!-- Use Fast inject so that this view isn't wired into the context --> <parsley:FastInject property="albumFormModel" type="{AlbumFormModel}" injectionComplete="handleInjectionsComplete()" /> <mx:Image width="15" height="15" id="activeImage" source="{(albumFormModel.isAlbumSelectionActive)?'images/active.png':'images/inactive.png'}" click="{albumFormModel.formFocusChangeHandler()}" autoLoad="true" toolTip="Click here to set this form active"/> <mx:FormItem label="Artist" required="true"> <mx:TextInput id="artist" width="290" text="{ albumFormModel.album.artist }" focusIn="{albumFormModel.formFocusChangeHandler()}" errorString="{ albumFormModel.artistError }" change="albumFormModel.updateArtist( artist.text )" enabled="{ albumFormModel.canEdit }"/> </mx:FormItem> <mx:FormItem label="Title" required="true"> <mx:TextInput id="title" width="290" text="{ albumFormModel.album.title }" focusIn="{albumFormModel.formFocusChangeHandler()}" errorString="{ albumFormModel.titleError }" change="albumFormModel.updateTitle( title.text )" enabled="{ albumFormModel.canEdit }"/> </mx:FormItem> <mx:FormItem> <mx:CheckBox id="classical" label="Classical" width="290" selected="{ albumFormModel.album.isClassical }" focusIn="{albumFormModel.formFocusChangeHandler()}" click="albumFormModel.updateIsClassical( classical.selected )" enabled="{ albumFormModel.canEdit }"/> </mx:FormItem> <mx:FormItem label="Composer" required="{ albumFormModel.canEditComposer }"> <mx:TextInput id="composer" width="290" text="{ albumFormModel.album.composer }" focusIn="{albumFormModel.formFocusChangeHandler()}" errorString="{ albumFormModel.composerError }" change="albumFormModel.updateComposer( composer.text )" enabled="{ albumFormModel.canEditComposer }"/> </mx:FormItem> <mx:FormItem> <mx:HBox> <mx:Button id="apply" label="Apply" enabled="{ albumFormModel.canApply }" focusIn="{albumFormModel.formFocusChangeHandler()}" click="albumFormModel.applyChanges()"/> <mx:Button id="cancel" label="Cancel" enabled="{ albumFormModel.canCancel }" focusIn="{albumFormModel.formFocusChangeHandler()}" click="albumFormModel.cancelChanges()"/> </mx:HBox> </mx:FormItem> </mx:Form>
As mentioned above the view has a border and an icon bound to the isAlbumSelectionActive property in the albumFormModel. Also note the focusIn handlers set in the objects of the form which call the formFocusChangeHandler, discussed above.
Also note that I'm now using the injection complete event from <FastInject>. I'm using to know when the injection is complete so that when a new AlbumForm is created, it requests to be set as the Active Album.
Now let's look at AlbumBrowserModel.as:
package com.adobe.ac.model { import com.adobe.ac.delegate.AlbumDelegate; import com.adobe.ac.messages.ActiveAlbumFormChangeMessage; import com.adobe.ac.messages.SelectedAlbumChangeMessage; import com.adobe.ac.vo.AlbumVO; import mx.collections.ArrayCollection; import mx.collections.Sort; import mx.collections.SortField; /************************** * This is the presentation Model for the AlbumBrowser root view. * It encapsulates the behaviour and state of the album browser view. * * This is comes from Martin Fowler's post about Presentation Model, which * was then ported to Flex by Paul Williams. I adapted it to show the * same example implemented usign the Parsley Framework * * Martin Fowler http://martinfowler.com/eaaDev/PresentationModel.html * Paul Williams http://blogs.adobe.com/paulw/archives/2007/10/presentation_pa_3.html * Arturo Alvarado http://artinflex.blogspot.com/ */ [Bindable] public class AlbumBrowserModel { /**************** * The current list of albums that are selected */ public var albums : ArrayCollection; /***************** * The title displayed on the window */ public var windowTitle : String = "My Albums"; /****************** * The currently selected album of the list */ public var selectedAlbum : AlbumVO; /****************** * Message dispatcher used to notify that the current album * selected has changed */ [MessageDispatcher] public var messageDispatcher:Function; /************************** * Constructor * It calls the delegate to fill the list af albums and * then proceeds to sort them by title name */ public function AlbumBrowserModel() { var delegate : AlbumDelegate = new AlbumDelegate(); albums = delegate.getAlbums(); albums.sort = new Sort(); albums.sort.fields = [ new SortField( "title", true ) ]; albums.refresh(); } /********************** * Called whenever the user selects a new album from the list. * It proceeds to dispatch a message that indicates the current/previous * album and the newly selected album. It updates the selectedAlbum * property to reflect the change. */ public function changeSelectedAlbum( album : AlbumVO ) : void { if ( album != null ) { var selectedAlbumChangeMessage:SelectedAlbumChangeMessage = new SelectedAlbumChangeMessage(selectedAlbum, album); selectedAlbum = album; if (messageDispatcher != null) { messageDispatcher(selectedAlbumChangeMessage); } } else { windowTitle = "My Albums"; } } /************************ * Receives the selectedAlbumChangeMessage dispatched by changeSelectedAlbum. * The message can be intercepted and have the value of cancelled changed before * it returns to this view. * Currently the AlbumFormModel has an interceptor that assigns that value. * If the change is cancelled, it returns the selected item to the previously * selected item. */ [MessageHandler] public function handleAlbumChangeMessage(selectedAlbumChangeMessage:SelectedAlbumChangeMessage):void { if (selectedAlbumChangeMessage.cancelled) { selectedAlbum = selectedAlbumChangeMessage.previousAlbum; windowTitle = selectedAlbumChangeMessage.previousAlbum.title; } else { windowTitle = selectedAlbumChangeMessage.selectedAlbum.title; selectedAlbum = selectedAlbumChangeMessage.selectedAlbum; } } /************************ * Receives a message indicating that some form has become active. * The message indicates what the current album that form is viewing * so it updates the list to reflect that album change. */ [MessageHandler] public function handleActiveAlbumFormChangeMessage(activeAlbumFormChangeMessage:ActiveAlbumFormChangeMessage):void { if (activeAlbumFormChangeMessage.currentAlbum != selectedAlbum) { selectedAlbum = activeAlbumFormChangeMessage.currentAlbum; windowTitle = activeAlbumFormChangeMessage.currentAlbum.title; } } } }
This model has a small update. It now also has a handler for the ActiveAlbumFormChangeMessage. That is so that when we switch between active albums, the selected item in the list reflects what is the currently selected album in the active form. Simple little change.
Well, that's it. As you can see, the update was minimal, yet creates potential and new user features. The best part is that we really didn't have to "change" how things worked. They were setup in a way that easily adapted itself to new functionality.
I do hope you find this useful. Feel free to ask questions in the comments.
Great posts.
ReplyDeleteThanks
ReplyDeletenice post, very clear clarification, thanks
ReplyDeleteThank you.
ReplyDeleteGreat posts!
ReplyDeletePlanning any posts on modules or popups?
I'm struggling with getting a module loaded into a popup to work with Parsley...
Thanks,
ReplyDeleteGreat suggestion. I'll add that one shortly.
Really nice writeup! Should have discovered that earlier.
ReplyDeleteAlso having problems with modules. We'd like to implement a plugin mechanism in our application but I can't get the contexts correctly wired.
Thank you for a great series. What is the impact of using data binding and Parsly on large and complicated projects (a lot of complicated item renderers). In my recent work I have tried to avoid using binding (i.e. in skin classes, which get to be used a lot) in hope it would prevent processing overhead.
ReplyDeleteIs there a price to pay for using all of this?
@sensegot
ReplyDeleteThank you.
Parsley works very well with large complicated projects.
One thing that is important is that Parsley itself does not focuses on any particular architecture, so you are still free to handle things however you find convenient.
That said, for large projects, an MVC architecture might be a good idea, which Parsley easily pulls off.
One really nice thing about Parsley though is that you really don't have to use Flex's bindings to bind your views to your data models. You can use Parsley's messaging to update views more efficiently.
Overall you can dramatically reduce the need for Bindings while using Parsley.
When you say "a lot of complicated item renderers" what is "a lot"? 100s? All on display at the same time? Can they be cached if there are only a few on display so only a few are bound at a time?
There is always a price to pay. :) Parsley let's you disassociate (decouple) your code, and doesn't forces you to any particular pattern, which means that if you don't set a pattern yourself, you will have a really hard time following your project in the future, specially if it's big, and you do things differently all over the place.
Performance wise, Parsley is very efficient.
It is a bit hard for me to give you more specific answers without having more knowledge of your app.
Hello Art! Thank you for a swift reply.
ReplyDeleteI'm working on a on-line betting application, which needs to displaying a big list of tables, titles, subtitles. There's a up to 15 different item renderers used in a single this list. The list can a one time contain over 1000 instances of these various value objects.
Since Flex List control doesn't support multiple item renderers I needed to implement my own list component which does.
The component uses a factory pattern and an object pool to recycle visual components which get scrolled out of the visible area of the screen.
So in fact item renderers are not firmly bound to a single value object in the mother, but are assigned a different one each time they are pulled out of the object pool by using a simple setter. This setter then populates parts of the item renderer with the new data.
In order for the scrolling of the list to work fast, there application of the new value object to an item renderer must be as efficient as possible. That's the main reason I didn't use any data binding.
Now however I need to use the same list component for displaying a more dynamic data, which gets regularly updated from the server and needs to handle more user interaction and some animation.
The problem which needs to be solved concerns the persistence of the information regarding the animation. After the item renderers performing the animation get pulled from the object pool, they should show continue the animation as if it never stopped. So not only that the information should be stored somewhere in the model, but it should also be regulary updated despite the fact there's at the given moment no item renderer might be assigned to the value object.
As you see the problem is somewhat complex.
That is why I started looking for a design pattern which will help me solve this problem, or at least point me in the right direction.
That does sound like a very interesting project.
ReplyDeleteKeeping a model with 1000's of objects stored for fast access should not be a problem, unless you are shooting for mobile. As long as you have some quick way to locate your specific object, you should be just fine.
Going with a MVC patter should work for you. Your services update the model, so the model is up to date. The Item Renderers pull the data from the model.
You can easily use a presentation model for your item-renderers to keep them from being added to the context. Assuming that data isn't updated 10 times per second, you simply dispatch on message that says, the data has been updated. The PM of the item renderers listen to the message, re-pulls it's data from the model. This way you don't have binding assigned to each individual VO that isn't being viewed, and rather have all the Item Renderers re-pull their data if an only if, there has been an updated. If your data has some index, the message can include the index, then only the corresponding item renderer will re-pull it's data.
No binding needed-yet is sure looks it there is binding. You could say it's just another way of doing binding.
The best way to do this, is have a method on the model that updates the data on the model, and not have the service/command updated the data directly on the model. That way you can make sure that the message that notifies the listeners get's dispatched.
Dear Art
ReplyDeleteThank you for writing this series, I am sure it took you a long time. They have helped me to get my head round Parsley and the approach to take a significant project we are starting.
All the best
Giles
Dear Art, I'm with Giles. you're very generous with your knowledge. I've recently joined a team that need to ramp up on some of this stuff, and I could point them to your excellent explanations.
ReplyDeleteYou're a good man, if our paths ever cross, many beers are coming your way :D
George
This is by far the best introduction to a flex framework I have ever seen, and yet another reason to start with parsley.
ReplyDeleteStarting with mate I found Robotlegs much more appealing after some projects. With parsley, I hope (and chances are good) that I found a framework which offers everything I'm looking for.
Thank you for this series which should definitely get mentioned on the official parsley site.
Great articles, they really helped me!
ReplyDeleteThanks man.