Saturday, March 04, 2006

A re-usable "Prompt to Save Changes" component

Today I'm going to share a component that we built on our current project. In order to understand why we built it, let me describe a simple situation: A user is on a web page that lets him or her edit some information. (S)he makes some changes on the page, doesn't save the changes, and clicks on a menu item that navigates somewhere else. All of the changes just went bye-bye! This is not a good thing. So, we decided to create a component that will detect such a situation and prompt the user about what to do. We wanted the component to be re-usable and simple to integrate into our ADF Faces/JSPX pages so that we didn't have to write gobs of code for each new page.

I'm not going to show every single bit and byte of code, but there should be enough substance to show what's going on.


The Menu

First, let's take a look at the menu. We are using a menu created via managed beans in the faces-config.xml file. I won't go into the details here about how to do that, but the SRDemo demo application (available from the JDeveloper "Check For Updates" menu) has a good example of that. All of our pages in the application use the ADF Faces af:Page component as the starting point. The af:Page has a facet called "nodeStamp" that if you put a af:commandMenuItem in there and bind the value property of the af:Page to a menu model, you'll get nicely rendered multi-level menus. So, let's take a look at how the menu is set up. Here's a few snippets of code from our page template:

[af:page title="#{res['template.title']}" var="pg"
value="#{menuModel.model}"]
As you can see, the page has a menu model bean bound to the value property. The nodeStamp looks like this:

[f:facet name="nodeStamp"]
[af:commandMenuItem text="#{pg.label}"
disabled="#{pg.readOnly||!pg.shown}"
type="#{pg.type}" useWindow="#{pg.useWindow}"
id="menuNode"
actionListener="#{bb.menuActionListener}"
returnListener="#{bb.menuReturnListener}"
rendered="#{pg.shown}"/]
[/f:facet]
The main thing to notice here is the actionListener and returnListener properties. This example shows that the actionListener is bound to a method called menuActionListener in the backing bean; similarly, the returnListener is bound to a menuReturnListener bean.

That's really about it in terms of code in the jspx file.

Backing Bean Code

This is where things start to get a little interesting. If you remember, one of our design goals was to avoid having to write a lot of code for each and every screen. Well, we were able to accomplish our goal by creating a superclass to use as the parent for all of our backing beans. We actually need to write no extra code in each page's backing bean, because it turns out we were able to write the code in a generic fashion.

So, let's start out with the menuActionListener method. This is the method that is called when the user clicks on a menu item. What should happen when the user clicks a menu? Well, if there are no unsaved changes on the page, then the application should do whatever the user was requesting by clicking on the menu. But, what if ther e are changes on the page? In that case, what should the application do? Well, it turns out there are 2 cases here: first, if the user was clicking the "Help" menu, the application should go ahead and display the help. However, if the user was trying to navigate somewhere else, the application should display a dialog box and give the user a choice of what to do. Our dialog gives the user 3 choices: cancel (go back to the original page), save changes and continue (go to the requested page), or discard changes and continue (go to the requested page).

Before I get into the code, a word of, ummm, well, let's say "caveat emptor" about the dialog framework. We did find some unusual behaviors related to the ADF Faces dialog framework when we were building these components - some of them have been accepted as bugs in behavior. The main issues with the dialog fram ework that caused us heartache was the behavior when returning from a dialog. It turns out that, unless the "partialSubmit" property of whatever component initiates the dialog is "true", you cannot perform any navigation/page refresh/etc in the return listener of a pop-up dialog. To make a long story short, we decided to make our dialog open in the same window (set the useWindow property to false).

One other issue that we faced was how to determine whether the page had unsaved changes, or was "dirty," in colloquial terms. It would sure be a pain to have to compare each field's value with the database to determine if the record was dirty. After playing around, we noticed something... if you drop a commit button from the data control palette on to your page, by default it is only enabled if there are changes that need to be saved... hmmm... that looks like some behavior we could use. In that commit button, the "disabled" property is set to "#{!bindings.Commit.enabled}" so it turns out we can use that EL expression to determine if there are changes to save or not. The only requirement is that each page needs to have a Commit action binding, and most of ours do.

So, the code.... (all of this code lives in the class that is the superclass for all of our backing beans)




public void menuActionListener(ActionEvent ae)
{


String outcome;

// Determine the desired navigation outcome

outcome = (String) JSFUtils.resolveExpression("#{pg.outcome}");

// If it's help, allow it to proceed

if (outcome.equals("dialog:Help"))
{
performNavigation(outcome);
return;
}

// If the data is clean, allow the navigation to proceed, otherwise,
// display the "Confirm Navigation" dialog

if (!isDirty())
{
performNavigation(outcome);
return;
}
else
{
displayConfirmNavigationDialog(ae.getComponent(), outcome);
}
}
We use a few helper methods here:


protected boolean isDirty()
{
Boolean b;
// if the page has no bindings, it's by definition not dirty

if (_bindings == null)
{
return false;
}

b = ((Boolean) JSFUtils.resolveExpression("#{bindings.Commit.enabled}"));

// if there is no commit binding, the form is by definition not dirty

if (b == null)
{
return false;
}

return b.booleanValue();
}

Note that we inject "bindings" as a managed property into every managed bean. The bindings property actually exists in the superclass


private void displayConfirmNavigationDialog(UIComponent component, String outcome)
{
FacesContext context = FacesContext.getCurrentInstance();
ViewHandler vh = context.getApplication().getViewHandler();
UIViewRoot dialog = vh.createView(context, "/infrastructure/ConfirmSaveChangesBeforeNavigate.jspx");

AdfFacesContext a = AdfFacesContext.getCurrentInstance();
HashMap props = new HashMap();
HashMap params = new HashMap();

params.put("outcome", outcome);

a.launchDialog(dialog, params, component, false, props);

}
We put the desired outcome into the dialog's parameters so that when the dialog returns, we can navigate, if desired. Here's a little utility method that is used to perform navigation, given a JSF navigation case:


protected void performNavigation(String
outcome)
{
FacesContext context = FacesContext.getCurrentInstance();
NavigationHandler nh = context.getApplication().getNavigationHandler();

nh.handleNavigation(context, "", outcome);
}
The dialog looks like this:



That's really about it. Now, what happens when the user selects an option in the dialog? Control returns to the returnListener:


public void menuReturnListener(ReturnEvent returnEvent)
{
String rv = (String) returnEvent.getReturnValue();
String outcome;

if (rv == null || returnEvent.getReturnParameters() == null)
{
return;
}

outcome = (String) returnEvent.getReturnParameters().get("outcome");

if (rv.equals("Navigate:Cancel"))
{
return;
}

if (rv.equals("Navigate:Save"))
{
if (performSaveChanges())
{
performNavigation(outcome);
}
return;
}

if (rv.equals("Navigate:DontSave"))
{
if (performCancelChanges())
{
performNavigation(outcome);
}
return;
}
}


The return listener simply looks at what the user requested and performs the requested action. There are a couple of helper methods used there too:



protected boolean performSaveChanges()
{

BindingContainer bindings = getBindings();

OperationBinding operationBinding = bindings.getOperationBinding("Commit");

Object result = operationBinding.execute();

if (!operationBinding.getErrors().isEmpty())
{
return false;
}
return true;
}

protected boolean performCancelChanges()
{
DCBindingContainer bindings = getBindings();

bindings.getDataControl().getApplicationModule().getTransaction().rollback();

return true;
}
That's all! I appreciate if you'd leave a comment if you find this useful.

15 comments:

Anonymous said...

Absolutely that is useful. It's good to have an exposition on how to do that inside ADF Faces.

Anonymous said...

John,
Excellent stuff - a couple of thoughts spring to mind.
Firstly the issue of validations - You commandMenuItem stamp does not have immediate set one so is there a problem with truely cancelling if the user is in a invalid UI State - I'm not sure that there is a good solution to this because of course you do need to fully validate if the user wants to save then navigate - Humm need to think about that one...
Second point is one of outcomes. A flaw in my initial MenuItem code as published in SRDemo is that it only manages fixed outcomes making it hard to do any kind of further additional processing when a menu is selected. I had a need for just that this week so I have a slightly revised version of MenuItem that provides that functionality - I'll try and write that up this week in the blog.
Anyway great work - one for the toolkit.

John Stegeman said...

Duncan,

Thanks for the feedback. As to the menu not being immediate, I had thought about that and decided that a cancel button on the page with immediate=true was the best way to solve that issue. Obviously, it's a catch-22, if the menu is immediate, the model is not updated when the user clicks on the menu and therefore users' changes may be lost. If immediate=false, then it's a minor annoyance when trying to navigate away when the data does not validate (hence the cancel button). Note that SRDemo also has this problem (in the two-step create a new SR function) that you cannot cancel if you don't have a name filled in for the SR, as the name is required.

Anonymous said...

What are the actual menu item beans themselves? Is it necessary to create my own bean for menu items? I feel that would be reinventing the wheel since every bean is going to have the same properties: text, outcome, etc. On the other hand, trying to use built-in ADF classes (like CoreCommandMenuItem) runs into problems because in order to use a ViewIdPropertyMenuModel, my bean has to have a viewId property, but CoreCommandMenuItem doesn't have one.

Thanks

John Stegeman said...

Well, I simply "snagged" the MenuItem class from the SRDemo sample application, and made a few changes to it to support some dynamic permissions . I have the entire menu structure defined in faces-config.xml as "none" scoped beans. I know that Steve M has recently blogged about another approach, but I haven't really had time to look at it. Hope this helps

Anonymous said...

Implementing a dynamic menu with security,Can you tell me How solved these issues.
please email some code to zcjsh@126.com ,I'm study;
Think you very much!

Sisir said...

Hi John,

How to achieve the same functionalities using a popup dialog (usewindow=true)? Would you please give me one example?

Regards,
Sisir

Michel Dietrich said...

Sisir,

1. call
a.launchDialog(dialog, params, component, true, props);
instead of
a.launchDialog(dialog, params, component, false, props);

when you have created the Dialog, generate a bean and implement this methods:

public void onSave(ActionEvent actionEvent) {
// Add event code here...

FacesContext fc = FacesContext.getCurrentInstance();
ValueBinding vb =
fc.getApplication().createValueBinding("#{processScope.outcome}");

String outcome = (String)vb.getValue(fc);

HashMap params = new HashMap();
params.put("outcome", outcome);

AdfFacesContext.getCurrentInstance().returnFromDialog("Navigate:Save", params);

}

public void onDontSave(ActionEvent actionEvent) {
// Add event code here...

FacesContext fc = FacesContext.getCurrentInstance();
ValueBinding vb =
fc.getApplication().createValueBinding("#{processScope.outcome}");

String outcome = (String)vb.getValue(fc);

HashMap params = new HashMap();
params.put("outcome", outcome);

AdfFacesContext.getCurrentInstance().returnFromDialog("Navigate:DontSave", params);

}

public void onCancel(ActionEvent actionEvent) {
// Add event code here...

FacesContext fc = FacesContext.getCurrentInstance();
ValueBinding vb =
fc.getApplication().createValueBinding("#{processScope.outcome}");

String outcome = (String)vb.getValue(fc);

HashMap params = new HashMap();
params.put("outcome", outcome);

AdfFacesContext.getCurrentInstance().returnFromDialog("Navigate:Cancel", params);

}

The buttons should have the the right methods as actionlistener.
Then you can reach the returnparameters() from the dialog.

Anonymous said...

Applying this to a button, with actionListener determining whether to launch the dialog(window=false) and hand coding outcome in the performSave & performCancel method instead.

The onOk actionListener on the ok button of the dialog has only one line of code

AdfFacesContext.getCurrentInstance().returnFromDialog("Navigate:Ok", null);


Got to the point that on clicking save/ok in the dialog, the return to the caller page happened but got a lot of error in console

oracle.jbo.JboException: JBO-29000: Unexpected exception caught: java.lang.NullPointerException, msg=null
at oracle.adf.model.binding.DCIteratorBinding.reportException(DCIteratorBinding.java:309)
at oracle.adf.model.binding.DCIteratorBinding.callInitSourceRSI(DCIteratorBinding.java:1439)
at oracle.adf.model.binding.DCIteratorBinding.getRowSetIterator(DCIteratorBinding.java:1411)
at oracle.adf.model.binding.DCIteratorBinding.getNavigatableRowIterator(DCIteratorBinding.java:1593)
at oracle.adf.model.binding.DCIteratorBinding.refreshControl(DCIteratorBinding.java:556)
at oracle.adf.model.binding.DCIteratorBinding.refresh(DCIteratorBinding.java:3466)
at oracle.adf.model.binding.DCBindingContainer.refreshExecutables(DCBindingContainer.java:2637)
at oracle.adf.model.binding.DCBindingContainer.internalRefreshControl(DCBindingContainer.java:2568)
at oracle.adf.model.binding.DCBindingContainer.refresh(DCBindingContainer.java:2260)
at oracle.adf.controller.v2.lifecycle.PageLifecycleImpl.
prepareModel(PageLifecycleImpl.java:99)


which seems to indicate all pageDef definition in the calling page is no longer accessible.

After these error, the returnListener bounded to the save button got call and Navigation:Ok was returned. Then again executeCommit failed with similar error.

The caller page is working fine without hooking up this prompt page and as such the pageDef for it should be good.

Any idea or help?

Anonymous said...

This is great, how would you do this type of behaviour when you selected another control on the current page?
I am trying to convert an Oracle form into an ADF JSPX page and when they add records in the detail table and then select a different row in the master table it prompts them to save the changes.
Thanks for any help.

stunning said...

You don't have the code for the
BeforeNavigate jsp do you?

I don't know how to create a dialog box.

Thanks

Anshuman said...
This comment has been removed by the author.
Anonymous said...

Guild Wars is an web episodic series of multiplayer hair online role-playing games developed by ArenaNet and lpn online published by NCsoft

Sildenafil Citrate said...

interesting component, so what this component does is to give you a warning that you are going to abandon the current page without saving the changes and the component asks you something like "are you sure you want to leave this page without saving the changes?"?

Anonymous said...

This is really post, you made my day. thanks a lot