Thursday, March 17, 2011

jQuery table sorter meets salesforce

jQuery is a powerful tool, and so I always use it in my APEX projects to help accomplish what I wouldn't be able to with just apex/html/css. One of the things I almost always include is the Tablesorter, but there are a few issues when trying to make it work with Salesfroce standard page markup tags. 
All the documentation for the tablesorter is on their page: Tablesorter, but some points to remember are that if you are using any apex:output(filed/text) that it wraps a span around the text and the tablesorter needs to know that, also if you use an 'a' tag it behaves differently as well. Also there seems to be an issue with using the default sorter params, ie letting the tablesorter pick them for you.
To show you a good example of the jQuery table sorter in action with salesforce, I will be using the 'User Info List' that I recently just updated with it.

(NOTE: that table sorter requires the class tablesorter  on the table tag in order to work)
Example Table markeup:
<td><apex:outputfield value="{!c.Email}" /></td>
<td>{!c.Name}</td>
<td><span><apex:outputfield value="{!c.Phone}" /></span></td>
<td><a target="_blank" href="/{!c.Id}" title="go to Contact">Contact</a></td>

JS markup:
//this checks the table for data on three levels
      //<td>the data</td> $(node).text();
      //<td><node>the data</node></td> $(node).next().text();
      //<td><node><node>the data</node></node></td> $(node).next().next().text();
//this ensures that the data is found no matter how many nodes it needs to transverse.
var myTextExtraction = function(node) 
{  
      var nodeText = $(node).text();
      if(nodeText == null || nodeText == ''){
            nodeText = $(node).next().text();
      }
      if(nodeText == null || nodeText == ''){
            nodeText = $(node).next().next().text();
      }
      return nodeText;
}
//example markup to use the node traversing and establish the sorter params
$("table").tablesorter({
       textExtraction: myTextExtraction,
       headers: {
           0: {sorter: 'text'},
           1: {sorter: 'text'},
           2: {sorter: 'text'},
           3: {sorter: 'text'},
           4: {sorter: 'shortDate'
       }
   });

Now lets look at the User-Info-List page, you'll notice that instead of using the standard apex:pageblocktable I am using a standard table, BUT I have included the style(classes/structure) of what the apex:pageblocktable would have... meaning that if you right click and view source on an apex:pageblocktable you will see the same as below (style/class wise minus a few things for my own customizations) and so when this page renders, it looks like an apex:pageblocktable. I do it this way due to the lack of flexibility in the standard apex:pageblocktable, and the blocktable seems to clash with the jQuery tablesorter when used naively. 


The Page:
<apex:page controller="UserInfoList" standardStylesheets="false" title="User Info" action="{!SwitchList}">
<apex:stylesheet value="{!URLFOR($Resource.UserList, '/NewTQC.css')}"/>
<apex:stylesheet value="{!URLFOR($Resource.UserList, '/style.css')}"/>
<script type="text/javascript" language="javascript" src="{!URLFOR($Resource.UserList, '/js/jquery1_4.js')}" ></script>
<!-- always include the tablesorter script after the jQuery -->
<script type="text/javascript" language="javascript" src="{!URLFOR($Resource.UserList, '/js/jquery.tablesorter.min.js')}" ></script>
<apex:form >
<div id="pageWrap">
<!--wrap the whole page in an outputpanel so that ajax rerenders, it triggers the jQuery.ready() function-->
<apex:outputPanel id="TicketListWrap" styleClass="Wrap">
      <script language="javascript" type="text/javascript">
            //controls the loading gif
            function Loading(b){
                  if(b){
                        $('.loadingIcon').removeClass('hideIt');
                  }else{
                        $('.loadingIcon').addClass('hideIt');
                  }
            }
            $(document).ready(function(){
                  //important to specify the sorting param otherwise it wont always choose the 'correct' one
                  $("table").tablesorter({
                    headers: {
                        0: {sorter: 'text'},
                        1: {sorter: 'text'},
                        2: {sorter: 'digit'},
                        3: {sorter: 'digit'},
                        4: {sorter: 'text'},
                        5: {sorter: 'text'},
                        6: {sorter: 'text'
                    }
                });
            });   
      </script>
      <div class="clear"></div>
      <br />
      <div id="newTicketHeader" class="floatL">Users in {!sDepartment}</div>
      <div class="floatR">
            <strong>Department:</strong>
            <apex:selectList id="department" size="1" value="{!sDepartment}" >  
                  <apex:selectOptions value="{!lstDepartmentOptions}" />
                  <!-- every time the select list is changed it refreshes the list of users -->
                  <apex:actionSupport event="onchange" onsubmit="Loading(true);" action="{!SwitchList}" rerender="TicketListWrap"id="actionsupportforKnownIssues" oncomplete="Loading(false);" />
            </apex:selectList>
      </div>
      <!-- the loading icon -->
      <div class="loadingIcon hideIt"><apex:image id="loadingImage" value="{!URLFOR($Resource.UserList, 'images/loader_24x26.gif')}"width="24" height="26"/></div>
      <div class="clear"></div>
      <div style="margin-left:5px; font-size:10px;">User count: {!iUserCount}</div>
      <table id="Usertable" class="list tablesorter" cellspacing="1">
      <thead class="rich-table-thead">
            <tr class="headerRow">
                  <th colspan="1" scope="col">Name</th>
                  <th colspan="1" scope="col">Phone</th>
                  <th colspan="1" scope="col" style="width:50px;">Ext</th>
                  <th colspan="1" scope="col">ICQ</th>
                  <th colspan="1" scope="col">Title</th>
                  <th colspan="1" scope="col">Location</th>
                  <th colspan="1" scope="col">Action</th>
            </tr>
      </thead>
      <tbody>
            <apex:repeat value="{!CurrentListofUsers}" var="u" id="UserListRepeater">
            <tr class="dataRow">
                  <td>{!u.name}</td>
                  <td>{!u.phone}</td>
                  <td>{!u.Extension}</td>
                  <td>{!u.ICQ__c}</td>
                  <td>{!u.Title}</td>
                  <td>{!u.Location__c}</td>
                  <td>
                        <div>
                              <!-- only current user can edit their own info -->
                              <apex:outputlink value="/{!u.id}/e?retURL=apex/UserInfoList" rendered="{!IF(u.id == $User.Id, true, false)}" >Edit Info</apex:outputlink>
                        </div>
                              <!-- only manager's/Team lead's/VP's can edit the users Queue info -->
                        <apex:outputpanel rendered="{!IF(CONTAINS($UserRole.Name, 'Manager') || CONTAINS($UserRole.Name, 'VP') || CONTAINS($UserRole.Name, 'Team Lead'), true, false)}">
                              <apex:outputlink value="/apex/QueueMemberEdit?UserId={!u.id}" >Edit Queues</apex:outputlink>
                        </apex:outputpanel>
                        
                  </td>
            </tr>
            </apex:repeat>
      </tbody></table>
      <hr />
</apex:outputPanel>
</div>
</apex:form>
</apex:page>



The classes that power it:
public with sharing class UserInfoList {

      public string sDepartment{get;set;}
      public string sCurrentUserDepartment
      {
            get{return UserInfoListHelper.FindDepartment(UserInfo.getUserId());}
            set;
      }
      public List<User> CurrentListofUsers
      {
            get{return UserInfoListHelper.UserList( UserInfoListHelper.FindDepartment(sCurrentUserDepartment, sDepartment));}
            set;
      }
      public integer iUserCount
      {
            get{return CurrentListofUsers.size();}
            set;
      }
      public List<selectOption> lstDepartmentOptions
      {
            get{return UserInfoListHelper.DepartmentKeys();}
            set{lstDepartmentOptions = value;}
      }
            
      public pageReference SwitchList()
      {
            if(!GlobalHelper.CheckForNotNull(sDepartment))
            {
                  sDepartment = sCurrentUserDepartment;
            }
            return null;
      }
}

public with sharing class UserInfoListHelper {
      /// <summary>
      /// OVERLOADED
    /// FINDS THE USERS DEPARTMENT BASED ON ID PASSED
    /// </summary>
    /// <param name="lstInValueToCheck">USER ID</param>
    /// <returns>USER DEPARTMENT</returns>
      public static string FindDepartment(string sInUserId)
      {
            if(GlobalHelper.CheckForNotNull(sInUserId))
            {
                  try
                  {
                        User u = [select id, Department from User where id=:sInUserId limit 1];
                        return u.Department;
                  }
                  catch(exception e)
                  {
                        system.debug('UserInfoListHelper - FindDepartment - error: '+e);
                  }     
            }
            return null;
      }
      /// <summary>
      /// OVERLOADED
    /// RETURNS USER DEPARTMENT IF SELECTED DEPARMENT IS NULL
    /// </summary>
    /// <param name="sInDepartmentSelection">USER DEPARMENT</param>
    /// <param name="sInUserDepartment">SELECTED DEPARTMENT</param>
    /// <returns>DEPARTMENT</returns>
      public static string FindDepartment(string sInUserDepartment, string sInDepartmentSelection)
      {
            if(GlobalHelper.CheckForNotNull(sInDepartmentSelection))
            {
                  return sInDepartmentSelection;
            }
            return sInUserDepartment;
      }
      /// <summary>
    /// ADDS DEPARTMENT KEY NAMES TO LIST OF SELECT OPTIONS
    /// </summary>
    /// <param name="lstInUser">USER ID</param>
    /// <returns>DEPARTMENT SELECT OPTION LIST</returns>
      public static List<selectOption> AddItemToList(List<User> lstInUser)
      {
            List<SelectOption> lstOptions = new List<SelectOption>();
            if(GlobalHelper.CheckForNotNull(lstInUser))
            {
                  for(User u : lstInUser)
                  {
                        if(!GlobalHelper.ContainsItem(u.Department, lstOptions))
                        {
                              lstOptions.add(new SelectOption(u.Department,u.Department, false));
                        }
                  }
                  return lstOptions;
            }
            return null;
      }
      /// <summary>
    /// FINDS ALL DEPARTMENTS AND ADDS THEM TO LIST OF SELECT OPTIONS
    /// </summary>
    /// <returns>DEPARTMENT SELECT OPTION LIST</returns>
      public static List<selectOption> DepartmentKeys()
      {
            User[] u = [Select Department From User where Department != null and IsActive = True limit 200];
            if(GlobalHelper.CheckForNotNull(u))
            {
                  return UserInfoListHelper.AddItemToList(u);
            }
            return null;
      }
      /// <summary>
    /// GETS LIST OF USERS BASED ON THE DEPARTMENT PASSED
    /// </summary>
    /// <param name="sInDepartment">DEPARTMENT</param>
    /// <returns>USER LIST</returns>
      public static List<User> UserList(string sInDepartment)
      {
            if(GlobalHelper.CheckForNotNull(sInDepartment))
            {
                  try
                  {
                        User[] u = [select Name, Phone, Extension, Title, ICQ__c, Location__c,Department from User where IsActive = True AND Department =:sInDepartment Order By Name ASC limit 200];
                        return u;
                  }
                  catch(exception e)
                  {
                        system.debug('UserInfoListHelper - UserList - error: '+e);
                  }
            }
            return null;
      }
}
Questions? 
Twitter: @SalesForceGirl  or Facebook 

20 comments:

  1. This is very useful. Thanks!
    I have a case very much similar to this.
    I have a 5 column table with around 200-300 records to display & sort with each header. One of the table column in input (actually a selectoption) by enduser.
    I used the same code & it worked great!

    But after trying the sorting few times, now I have some problem. I am loosing the user input data after sorting. The user input data is not passed back to class to get stored in SF. If I dont sort any column, the data gets stored perfectly. This gets worse as days pass. Initially I had this problem only if I sort the data more than 10-15 times. Now even if I sort 1 time, all the data is being lost.

    Am I missing something?
    Please help.

    ReplyDelete
  2. I would need to see code samples, could you post it?

    ReplyDelete
  3. This comment has been removed by the author.

    ReplyDelete
    Replies
    1. Unfortunately with IE9 at least that I heard, its still not fully supported with Salesforce, even though they say it is on their site and such. I had an issue with IE9 only and a few long calls with suport, I finally got them to admit that they only say they are compliant so they could get everyone using it and report the last bugs with salesforce and IE9. SO if you can confirm that it works with every browser BUT IE9 then I would contact support.

      Are you using the jQuery no Conflict? although i dont show it here, i use it almsot religiously with SFDC so i dont have to deal with script issues that happen way to often due to their code interfering with mine.

      Delete
  4. I believe the issue is the same as this persons

    http://boards.developerforce.com/t5/Visualforce-Development/jQuery-Table-Sorter-not-working-with-VF-after-few-sorting/td-p/427427

    The issue was related to IE9 in normal mode... Switching to compatibility mode works.

    I have a question... I am super green to classes and java script. But I need to sort a table on a visual force page. I really need help :(

    ReplyDelete
  5. Wow you got to that last message before i could delete it and post the corrected version :)

    ReplyDelete
    Replies
    1. try the no conflict with JS, this means you will need to put jQuery.noConflict(); at the top of the page and then replace your '$' with 'jQuery'

      Delete
    2. as a side note, when you re-render the table, you need to re-render the js so it will work again, to do this, wrap the JS in an outputpanel and include id in the rerender="someId" attribute on the commandbutton or link.

      Delete
    3. Terribly sorry for the confusion Randi... Initially I was merely contributing to the issue the other poster had. The link I submitted was how I found your site and all of it's wondermous salesforce glory.

      I am in the need of making a "sortable" table myself and found that this option with your suggested tablesort usage to be the best choice instead of going the long way and making lots of classes to have apex enabled sorting.

      I am new to apex coding so I'm still trying to grasp the concepts of how the classes are built. I do have some familiarity with C++ and I have a good concept of how to create a Visualforce page. I just need some guidance on how to create a class useful enough to give me what I want with this table. I am having a hard time finding resources that actually teach what does what in an apex class.

      Delete
    4. no worries, i learned through looking at how others coded, and using the search on developerforce.com

      you can also use :

      http://www.salesforce.com/us/developer/docs/apexcode/index.htm
      http://www.salesforce.com/us/developer/docs/pages/index.htm

      both VERY helpful.

      what obj do you need to display?

      Delete
    5. Thank you for the links. They are definitely very helpful. I am also signed up for an Intro to force.com webinar today.

      The object is a simple custom I created for our call center to store agent stats for ranking, breaks, work schedlue, ect..

      I was wanting to use tablesort for the stats ranking table as it needs to have an option to sort each column by rank. I have it created as a parent object with child object via related list that holds the agent profiles and fields.

      As an example for the table I have with standard apex code to display stats each rep I would use:

      <apex:pageBlockTable value="{!agent_page__c.reps__r}" and then have a column for each field I want to display starting with a column for name, other_stat, other_stat, etc.

      As you can see it's nothing too elaborate.

      Delete
    6. The first thing i would say is not to use the standard apex:pageBlockTable mainly because it doesn't give you the control you need for the table sorter i use above. (you will find this a lot with visualforce)

      in order for me to help you correctly, i have a few questions on your obj structure and why you did it in that way... like all development, there is more than one way to do it, and if i have better insight on why you have it set up this way it would help.

      Can you walk me though your thinking on how the page will work? the data you want to show in the table is stored in the reps__c obj under the agent_page__c obj? What purpose does the agent_page__c surve here? Can you by pass it and go straight to the reps__c obj? Is your page using a standard controller or are you doing like i am above with your own controller? where will this page be displayed? will it be on its own or will be nested in another page?

      This is why it helps to see the code/page..

      Delete
    7. Omgoodness, this turned into quite a long post. I hope you don't hate me for it :P I didn't think it would be this long...

      The page will not be nested and the end result will be a visual force page to display to the department all the data that is sent out in separate emails (break schedule, works schedule, monthly stats ranking, ect) all in one location.

      I chose to go with a parent/child because this will initially be used for two departments (two visualforce pages identical in layout/format, but shows different data) and then I could just add more departments later if I wanted to. Each department (agent_page__c) has it's own departmental goals and other unique things I need to also have displayed the visualforce page...(My current agent_page__c page layout doesn't show this yet as I wanted to tackle the sorting table for the reps__c first since it's the most difficult task in this project).

      I figured to make things more organized I would just have a related list child so when I create a new department I could just create a new reps (reps__c) list for each department.

      To answer "Can you by pass it and go straight to the reps__c obj" - I suppose I could, but I would still need to display the department goals and other info.

      My naming scheme will be changed as I'm just testing this all out right now. I'll probably change keep the reps__c, but rename agent_page__c if I decide to continue the route I'm going.

      Also, In the very beginning I did think of just using reps__c only and have all the profiles under it, but I couldn't figure out how to only display specific rep profiles for that department in the table... Since then I have seen some resources which lead me to believe I can do a simple render="fieldHere" in the table and have a unique filed associated to the rep record and this would allow me to pick and choose what is displayed on the table, but I haven't tried playing with this since going the parent/child route)

      Here is a link to screenshots of what I have so far and the code https://docs.google.com/open?id=0B_0RnZQ0lGSBS0lRYkpKLUw2OUU

      I am currently just using the few fields I have in there as test fields to give me an idea how the page is going to look and will create all the rest of the fields once I have the tablesort dialed in. I chose to link the names so if any small changes need to be made they can go to the specific record and make any edits needed.

      Wow those few questions you asked have really got my wheels turning thinking of new ways to attack this. It looks like I have to go back to the drawing board on this one... I am sorry if I am taking up too much of your time, but I really really do appreciate your assistance.

      Is this enough info? I don't want to flood your comments section with an unrelated topic. Would it be ok to continue chatting about this over another medium?

      Delete
    8. So from what i see, you are using the standardcontroller for the obj agent_page__c which is fine if thats all you need. let me see if i can make it work with that for now, so you dont have to re-architect everything..

      do you want only the bottom table to be sortable? or the top tables too? Also i noticed that you put the full SFDC url in your outputlink... dont do that, it will brake when you deploy since it will still be pointing to the sandbox and not prod. in SFDC you can just specify value="/id" it knows that you mean in this org and so will prepend the first part of the URL for you.

      if i were to apply it to your bottom table only this is how it might look:

      https://lh4.googleusercontent.com/-cqpTa8xLCiw/T6GbYNTC9mI/AAAAAAAACsw/5wMQjHImKk4/s882/Screen+Shot+2012-05-02+at+1.38.01+PM.png

      Delete
    9. Oh that makes so much sense! I forgot for a moment that I was working in the sandbox. Thanks for the value="/id" tip. Also, I guess I was getting caught up in the idea that I needed a class for the table to work...

      As you have so brilliantly pointed out that isn't necessary. But this is a good thing as it gives me more time to get educated on classes. I'm gonna get to work on implementing your suggestion. I can't thank you enough!

      Delete
    10. as an fyi i messed up a bit in the screenshot i gave you... in the repeat part...

      instead of telling you, can you see it? :-)

      Delete
    11. hehe Yea I caught the value={} and the var as I was reviewing it.

      I haven't yet tried to do anything since I don't have any static resources to call and haven't downloaded jquery yet to get them. For me to make this tablesort work should I have to download the main jquery ui and put the tablesorter plugin into it and create a zip or will it already be zipped up?

      Sorry if it seems like I'm asking some newbie ass questions... I was thrown into the SF admin spot because I am the only one with programming knowledge lol. I'm definitely going to take advantage of this an learn as much as I can so I can keep this position :)

      Delete
    12. no worries :-)

      but if you want to learn, I cant tell you the answers, you need to try to figure out the resources on you own... I will give you some hints, and if by tomorrow you give up i will give you the easy way out :-) but you'll need to get really good at searching and reading code from others in order to survie with SFDC. And the more you can rely on yourself to find the answer and debug problems the better you'll be with salesforce since its always presenting obstacles to jump over... trust me.

      go to developerfoce.com and search "Static Resource" from there you should get a verity of good sources to learn how to do it.

      and remember, you have an example of how it should look on the page above, i gave you a hint at the structure in the screen shot i gave you. And when you create the resource and go to upload it, you will have to zip it up if you want to upload multipal files/folders... other wise you can only add one file(not a folder though, its either one file(image/js/css/etc) or one .zip)

      Delete
    13. You're the best!!! It works flawlessly!

      I initially ran into an issue where the page wasn't calling the functions. Turned out this was because the zip file was effed up some how! NEVER LET WINDOWS COMPRESS YOUR ZIPS!!! I've never experienced this before so never knew to distrust it. I am forever scorned... Once I zipped it up with winrar it works great!

      Now I get to play with the formatting. Going to use some css scripts for that which I can include those in my resource file that I know how to use yayyyy.

      I won't forget the valuable lessons you have taught me. I couldn't have done it with out you. Now to continue on my long journey towards being SF developer!

      Delete
  6. This comment has been removed by the author.

    ReplyDelete