Tuesday, July 19, 2011

Custom Control for "enhanced" validation messages

Update 22.06.2012: Now shows messages not bound to any control. E.g. messages related to using concurrencyMode
Update 08.06.2012: Added sorting routine to get the messages in the same order that they're in the page
Update 22.07.2011: I added functionality to select/focus a dijit tab if the field is inside a dijit.layout.TabContainer. I also added a highlight effect when a field is focused.
Disclaimer: This custom control is not entirely my idea. I've been thinking about doing something like this for a while. After I tried to help with this question by Steve Pridemore in the XPages Development Forum, I found the solution.
The code below can be used as a custom control that is a little bit more advanced (probably has its flaws) than the regular Display Errors control. If the field with a validation error has a label, it shows the label, then the error message. On the label, a link is generated that sets focus to the related field when you click it.
<?xml version="1.0" encoding="UTF-8"?>
<xp:view xmlns:xp="http://www.ibm.com/xsp/core">
 <xp:this.beforeRenderResponse>
  <![CDATA[#{javascript:function addChildrenClientIds(component:javax.faces.component.UIComponentBase, clientIds:java.util.ArrayList) {
 try {
  var children = component.getChildren();
  
  for (var child in children) {
   clientIds.add(child.getClientId(facesContext));
   if (child.getChildCount() > 0) {
    addChildrenClientIds(child, clientIds);
   }
  }
 } catch (e) {
  /*Debug.logException(e);*/
 }
}

try {
 var messageObjects = [];
 var messageClientIds = facesContext.getClientIdsWithMessages(); 
 
 // There are messages for components - Get client ids in sorted order
 if (messageClientIds.hasNext()) {
  var clientIds = new java.util.ArrayList();
  addChildrenClientIds(view, clientIds);
 }
 
 // Used to keep track of which messages are for components
 var componentMessages = new java.util.ArrayList();
 
 while (messageClientIds.hasNext()) {
  var clientId = messageClientIds.next();
  if( !clientId ){ continue; }
  
  var component = view.findComponent( clientId.replace( view.getClientId( facesContext ), '').replace( /\:\d*\:/g, ':') );
  if (!component) { continue; }
  
  // Fetch messages for component
  var message = '',
  messages = facesContext.getMessages( clientId );
  while (messages.hasNext()) {
   var messageItem = messages.next();
   message += (message) ? ', ' : '' + messageItem.getSummary();
   
   componentMessages.push( messageItem );
  }
  
  // If component has label - fetch
  var labelComponent = getLabelFor(component);
  var label = (labelComponent) ? labelComponent.getValue() : '';
  if (!label && component) {
   var id = component.getId();
   if (id.indexOf('_') > 0) {
    label = id;
   }
  }
  
  if (label && label.indexOf(':') === -1) {
   label += ':';
  }
  
  messageObjects.push({
   index : clientIds.indexOf(clientId),
   clientId : clientId,
   label : label,
   message : message
  });
 }
 
 // Sort message object by the order of the components in the page
 messageObjects.sort(function (a, b) {
  if (a.index > b.index) { return 1; }
  if (a.index < b.index) { return -1; }
  return 0;
 });
 
 // Add all (if any) system messages at the top
 var allMessages = facesContext.getMessages();
 while( allMessages.hasNext() ){
  messageItem = allMessages.next();  
  if( !componentMessages.contains( messageItem ) ){   
   messageObjects.unshift({ message: messageItem.getSummary() });
  }
 }
 
 viewScope.messageObjects = messageObjects;
} catch (e) { 
 /*Debug.logException(e);*/
}
}]]></xp:this.beforeRenderResponse>
 <xp:scriptBlock>
  <xp:this.value><![CDATA[var EMessages = {
 // Set focus to field
 setFocus: function( clientId ){
  var matchingFieldsByName = dojo.query('[name=' + clientId + ']');
  if (matchingFieldsByName.length > 0) {
   if (dijit && dijit.registry) {
    this.showDojoTabWithField(clientId);
   }
   var field = matchingFieldsByName[0];
   
   // Workaround for dijit fields
   if( field.getAttribute( 'type' ) === 'hidden' ){
    var matchingFieldsById = dojo.query('input[id=' + clientId + ']');
    field = matchingFieldsById[0];
   }
   
   field.focus();
   dojo.animateProperty({
    duration : 800,
    node : field,
    properties : {
     backgroundColor : {
      start : '#FFFFEE',
      end : dojo.style(field, 'backgroundColor')
     }
    }
   }).play();
  }
  return false;
 },
 
 // If field is inside a dijit/extlib TabContainer - activate
 showDojoTabWithField: function( clientId ){
  dijit.registry.byClass("extlib.dijit.TabContainer").forEach(function (tabContainer) {
   dojo.forEach(tabContainer.getChildren(), function (containerPane) {
    if ( dojo.query( containerPane.containerNode ).query( '[name="' + clientId + '"]' ).length > 0) {
     tabContainer.selectChild(containerPane);
     return;
    }
   });
  });

  dijit.registry.byClass("dijit.layout.TabContainer").forEach(function( tabContainer ){
   dojo.forEach( tabContainer.getChildren(), function( containerPane ){
    if( dojo.query( containerPane.containerNode ).query( '[name=' + clientId + ']' ).length > 0 ){
     tabContainer.selectChild( containerPane );
    }
   });
  });
 }
}]]></xp:this.value>
 </xp:scriptBlock>
 <xp:repeat id="messageRepeat" styleClass="xspMessage" rows="30" value="#{viewScope.messageObjects}" var="messageObject">
  <xp:this.rendered><![CDATA[#{javascript:return ( viewScope.messageObjects && viewScope.messageObjects.length > 0 ); }]]></xp:this.rendered>
  <xp:this.facets>
   <xp:text xp:key="header" escape="false">
    <xp:this.value><![CDATA[<ul>]]></xp:this.value>
   </xp:text>
   <xp:text xp:key="footer" escape="false">
    <xp:this.value><![CDATA[</ul>]]></xp:this.value>
   </xp:text>
  </xp:this.facets>
  <li>
   <xp:panel rendered="#{!empty(messageObject.clientId)}">
    <a href="#" onclick="return EMessages.setFocus( '#{messageObject.clientId}');">
     <xp:text escape="false">
      <xp:this.value><![CDATA[#{javascript:return (messageObject.label) ? messageObject.label : messageObject.message;
}]]></xp:this.value>
     </xp:text>
    </a>
   </xp:panel>
   <xp:text value="#{messageObject.message}" rendered="#{javascript:return (messageObject.label != '');}" />
  </li>
 </xp:repeat>
</xp:view>



The code should work with fields inside a single level repeat. I'm not sure about deeper nesting. Pop the custom control into the page like you would with the Display Errors control.

Feel free to use the code however you like. If you improve on it, please share with the community.

30 comments:

Stephan H. Wissel said...

Would make a nice submission to the XPages contest

Tommy Valand said...

Too little work went into it compared to the other submissions. If I decide to make it a little more advanced, I might submit it. :)

Steve Pridemore said...

@Tommy If you do submit this and win... do we get joint custody of the ipad? lol. btw, it's not the amount of time that goes into the control or how complex. It's about providing a feature that is needed and I think this is one and you should submit it. Just make sure it can drill all the way down the tree to find any component. I ended up using java to walk the tree to find the components...

Steve Pridemore said...

This was the inspiration for the way I'm getting a handle on the individual repeated controls

http://www.mindoo.de/web/blog.nsf/dx/18.07.2009191738KLENAL.htm?opendocument&comments

I had to change it up to use the clientId but it works...

Tommy Valand said...

Well.. The time limit is not over yet. I might expand the functionality a bit/submit it.

As you say, I got inspiration from your question. I don't want to get into an IP lawsuit over a potential iPad.. :P

Richard Cotrim said...

Great control, but I found a problem. If you have a combo box with a formula to get the values, as a DbColumn, the control does not build the list of objects. Unfortunately I'm not a expert in xPages, so I could not find the problem.

Unknown said...

I made one "improvement" to your code that makes the errors list display using the oneui css. I wrapped the repeat control in a ul tag, removed the br tag and wrapped the label/message lines in a li tag.

The result is padding, color, etc that follows that of the Display Errors core control.

Tommy Valand said...

I implemented your suggestion in the source and removed the wrapping xp:div, as it wasn't necessary.

I also added a space between the link and the message. :)

Rob:-] said...

Thanks for sharing this but ...

Why aren't all the words visible on this page? I tried it in Chrome and IE 9 and it looked the same.

Here's a screen-shot: http://screencast.com/t/aSIuVJg6W77N

It makes reading it a bit annoying.

Tommy Valand said...

I've increased the width of the blog to fit 1280x1024 resolution, with a fallback width of 95%.

I also changed the code blocks to wrap the content.

Sabina said...

Wow - this is beautiful - thank you soo much!!!!

Pipalia said...

Thank you Tommy - this sort be a part of the extension library if not already in there!!

Steph said...

This ist really a nice control.
But i still do have a problem.
I put the control into different djTabPanes in an djTabContainer on my Xpage. When I click the Save button the validation "runs" over all tabs.
Do you have any suggestions how to avoid this and let validation only run on the current tab?
I'm just starting with XPages so I'm thankful for any hint.

Tommy Valand said...

Workflow-wise, I'm not sure if I understand why you want this, unless you have one data source per dojo tab. Even if you had, wouldn't this make it less user friendly?

If I as a user would have to click every tab/save to make sure everything was correct, I would get really frustrated, really fast :)

Steph said...

Hi, Tommy,
thanks for your response so far.
And yes you're right by describing the case your way. But maybe I have to tell you a bit more about what I want/need to do to make it clear.
We work with forms, where we do have 3 tabs. In one tab the user can apply for a new authorization for certain applications, in the other tab he can apply for changes in certain applications and in the third tab he can ask for the deletion of the authorization for certain applications. So he only should be able to fill out the required fields of one tab at one request.
I'm thankful for any hint/help.

Tommy Valand said...

I think I uderstand how it's structured, but I'm not sure if I understand the workflow :)

Instead of tabs, three buttons that toggle the visibility of the panels containing each of the authorization requests, or one combobox with three choices + a button.

E.g.
The first button sets:
viewScope.showNewAuthorizationRequest = true
And partially refreshes a panel that contains all three authorization request forms (with computed visibility based on the scoped variables), so that the correct authorization forms is shown.

Or, if you have the extension library installed, use dialogs for the authorization request. If I understand your case correctly, I would imagine that this makes the workflow more streamlined and less confusing for the user.

Glenn said...

I've tried this and it works really nicely for me in a particular application where I make use of the "validation" built into the Xpage....

However, I have a different app that does all validation inside a button on the form before submitting. Is it possible to still use your control in some way ?
Glenn

Tommy Valand said...

Maybe, if you combine it with this function:
http://dontpanic82.blogspot.no/2010/10/xpages-add-globalfield-message.html

Try to "add a message to a component". You might get the same functionality as you would with regular validation on components. I haven't tested it, so I'm not sure.

Glenn said...

Tommy, with a bit of playing, that does in fact work. Thanks for the info...I really appreciate your response offering some further direction !!
Glenn
PS...I owe you some beers :)

Tommy Valand said...

I like your currency ;)

Unknown said...

Hi Tommy , Its really cool. I came across an issue with focusing on Date time picker which has a different type-style properties . The validation control does a good job in finding all invalid components but does not work while setting the focus inside the field for 'Date Time picker' control .
I added a workaround and it works .
// Workaround for dijit fields
if( field.getAttribute( 'type' ) === 'hidden' ){
var matchingFieldsById = dojo.query('input[id=' + clientId + ']');
field = matchingFieldsById[0];
}

Add to the above snippet this :
else if( dojo.style(field, "display") == "none"){

var matchingFieldsById = dojo.query('input[id=' + clientId + ']');

field = matchingFieldsById[0];

}
Cheers!

TheWelcomeIdiot said...
This comment has been removed by the author.
Lothar Mueller said...

This entry is nearly two years old now, and it's still the best validation solution I ever found. Thanks a lot for this!

One thing I'd like to ask though:
running your code in my current project I observed that in one particular cc the code obviously isn't finishing correctly. Doing some research I came across the fact that the edit box in question is part of a panel named "panelOverview". Now when the code starts to get to the components it runs this part:

clientId.replace( view.getClientId( facesContext ), '').replace(...);

resulting in a clientId like this:

:_id1:_id47:_panelOver:_id55:_inputText1

So obviously the string "view" (resulting from "view.getClientId(facesContext)") is replaced everywhere.

For me I solved this by using another regex like this:

clientId.replace(/\bview/, '').replace(...);

Works fine, but I wonder whether there is a chance that a clientId might NOT start with the term "view". Or in other words: is there a prticular reason why you would calculate that term and not use it as a fixed string?

all the best

Tommy Valand said...

That's a good question :)

It might have something to do with having the control inside an included page or an edge case with Custom Controls. To be honest, I can't remember why I did it like that.

Lothar Mueller said...

ok, thanks anyway. So I'll stick with my variation until it breaks... ;)

Lothar Mueller said...

Hi again,
made some more modifications to your code, maybe it's of interest for others, too:

mod #1 is in regards to the character replacement in "clientId" (see yesterday's comment). Instead of

var component = view.findComponent(clientId.replace(view.getClientId(facesContext),'').replace(/\:\d*\:/g, ':') );

I'm using a set of RegExp objects:

...
var regex1 = new RegExp('\\b' + view.getClientId(facesContext));
var regex2 = new RegExp('\\:\\d*\\:', 'g');
var cId = regex1.replace(clientId, '');
cId = regex2.replace(cId, ':');
var component = view.findComponent(cId);
...

mod #2 relates to the clientSide JS function setting the field focus (EMessages.setFocus). In my application I'm having one form where a button is used to write values into a write protected field. Since I couldn't find a proper way to validate said protected field I created an editable but "hidden" field (style="display:none") which also receives corresponding values from the button action. Works fine, but obviously I cannot set the focus to a hidden field. Instead I decided to set the focus to the button instead. To achieve this I created a custom attribute called "ctrlFor" for my hidden field, as in







The clientSide JS now tries to read this custom attribute and, if found, sets target to the clientId referenced in that attribute:

if(field.getAttribute('ctrlFor')!=null){
var matchingFieldsById = dojo.query('button[id=' + field.getAttribute('ctrlFor') + ']');
field = matchingFieldsById[0];
}

Appears to be working fine, although I have to admit that it might be quite on the complicated side; if someone has a simpler idea I'd be more than happy to learn it ;)

Best wishes,
Lothar

Lothar Mueller said...

sorry for yet another post; obviously I didn't take into account that this editor wouldn't like me pasting in xml code, so the example code for the custom attribute is missing.
Let me do it the verbose way:

under All Properties I added a custom attribute called "ctrlFor" and gave it a computed value returning the button's clientId like this:

getComponent(button1).getClientId(facesContext);

Paul said...

Hi Tommy,

look really nice. A big improvement over the stock implementation.

I have tried putting this into an Xpage with existing validation but have had no luck getting it to display any errors. Has the code been updated since you originally posted?

Cheers,
Paul

Paul said...

Sorry,

forgot to mention that my page has multiple data sources.
These are in the form of a repeat containing a panel bound to a datasource.

Is the control designed to work in a situation like this?

Paul

Tommy Valand said...

It hasn't changed. I still use the code in the post on projects.

The control should be general.

I you're doing a partial refresh, the enhanced messages control needs to be in the area that's being refreshed.

Throw an xp:messages (Error Messages?) control next to the enhanced messages control. If neither one shows any errors, I would think there's some code that crashes/the validation doesn't trigger.