Thursday, March 31, 2011

SalesForce Knowledge, a look Underneath

A little while ago I finished work on a support website that was housed on force.com sites and powered by knowledge, Salesforce's new KB solution. While it was fun to figure everything out, I thought I would share to help other avoid that same headache. 

Like all newly released software, it has some bugs and some issues that you will want to look out for, but overall, Salesforce did a great job on this one. Hopefully with some more updates it will reach its full potential and there will be less need for customization.

Creating articles in knowledge can be very troublesome, and the trouble can start sooner rather than later, so its important to carefully think out the best way to implement it based on the company's needs. Knowledge allows multiple 'DataCategories', which is what is used to help filter the search and organize the content. In the old Knowledge version(before 21.0) they didn't give much consideration to Language, so if multilingual support was needed, you would need to make sure you set theDataCategories appropriately before starting. Also if you wanted multilingual, you needed to figure out how to do the urls -> each doc requires a unique URL field(we ended up appending the lang code to the end). Otherwise you would have to go back thought and edit each doc you made to accommodate for that. With the newest release of knowledge (v21.0), it will have a required Language field that 'fixes' the need for it for so much for thought for such an otherwise simple option.


 The DataCategories themselves have their own issues, for one you can only edit / access them though the website, it is not available in the schema browser except though the article types, but only access based on that Artical type for filtering the query / search. On top of that, the interface to edit DataCategories in is, as far as i can tell, an attempt at improving the old edit pages with an ajax-ified 'high-tech' looking one. The page seems to be overloaded with onHovers, double-click to edit, ajax-rerenders and modals, to a point that loses usability, making it had to get at the data you need, thus rendering the page unusable. It does look nice though.

Creating different Articles Types for your knowledge base raises some more considerations that must be thought about before getting started. One nice thing is that you can create any number of Article Types, for example, my implementation has 9 including:
  • Alerts
  • Internal
  • Videos
  • Documents
  • FAQs
  • Release Notes
etc. On the surface, in the 'Article Management' tab, it gives you another 'ajax-ified' looking UI, that doesn't allow for easy sorting or viewing of the Articles. The table's columns are mostly sortable except by Type, they also allow you to filter by Data Category OR allow Queuing , making it impossible to manage anything more than a hundred articles. My Org has somewhere around  5000 articles across 3 languages and growing I might add. 


Unlike other sObject records in Salesforce, the Article Type obj has a required 'URL name' field that is also required to be unique. They also added attachments as a field type to the Article sObjects, but it can cause some unexpected errors saying that the DML limit has exceeded in certain use cases, and it had a few other quirks that just made working with them a headache. Since the out-of-the box fields on the sObject is minimal, its key to plan out each ArticalTypes fields carefully, and thoroughly to avoid having to back track later.
In making  an Article, the first thing to note is that Salesforce enforces the save draft, then publish principal, which would be good but without queues to sort the articles, it does little more than become a performance issue. Its also not possible to create and then publish Articles via apex, you can create a draft article, but not to publish or make it 'public'. 

The rich text editor that Salesforce uses is limiting since they are using a 'lite' version of another rich text editor called FCKeditor, making it necessary to compose the Article in HTML else where and copy / paste it into the editor. But as you would expect there is issue with this as well, since it was not designed for such use.

Looking at the Article in the schema browser you will notice that there are 5 sObject for every Artical Type, and each has a unique extension:

  • ArticalType__ka
  • ArticalType__kav
  • ArticalType__ViewStat
  • ArticalType__VoteStat
  • ArticalType__DataCategorySelection

The __ka sObject is the top level article, but it doesn't hold any of the actually article info like Body, Title, etc, that is housed in the __kav obj, which has access to the  __DataCategorySelection obj for searching. But if you want to see __ViewStat or __VoteStat, you need to go through the __ka object. This may seem like a pratical set up but the __ka object almost holds no value except to make it harder to transverse the layers, ideally it should be the __kav with everything underneath it. The __ViewStat and __VoteStat sObjects have a few drawbacks as well, if you were to have it on a public site, only users that login could rate Articles (__VoteStat), which means a Salesforce license for every potential user on the site. The __ViewStat however doesn't require log in, but with testing over time, we found that it would stop working periodically, and so wasn't reliable.

Example SOQL code with Data Categories:

List<FAQ__kav> f = Database.query('Select f.ranking__c,f.VideoUrl__c,MacVideoUrl__c,f.Localized_Title__c,f.KnowledgeArticleId From FAQ__kav f WHERE f.ranking__c != null and PublishStatus = \'online\' and IsVisibleInPkb = true WITH DATA CATEGORY Languages__c BELOW '+lang+'__c AND Products__c BELOW '+prod+'__c order by f.ranking__c DESC limit 10');

Example SOSL:

List<List<SObject>> qry = search.query('FIND \'access code\' IN ALL FIELDS Returning Documentation__kav(ID, knowledgearticleid, Title, Localized_Title__c, UrlName, LastPublishedDate WHERE PublishStatus = \'Online\' LIMIT 5),FAQ__kav(ID, knowledgearticleid, Title, Localized_Title__c, UrlName, LastPublishedDate WHERE PublishStatus = \'Online\' LIMIT 5) WITH DATA CATEGORY Products__c BELOW (lmipro2__c,lmifree__c) AND Languages__c BELOW (en__c,es__c)');

Example in accessing Data Categories:

               for (FAQ__kav f : faqList)
               {
                        FAQ__DataCategorySelection[] dc =  f.DataCategorySelections;
                        for(FAQ__DataCategorySelection d : dc)
                         {
                              if(d.DataCategoryGroupName == 'products')
                              {
                                    if(GlobalHelper.CheckForNotNull(dataCProd))
                                    {
                                          dataCProd = d.DataCategoryName;
                                    }else{
                                          dataCProd += ', '+ d.DataCategoryName;
                                    }
                              }
                              if(d.DataCategoryGroupName == 'languages')
                              {
                                    if(GlobalHelper.CheckForNotNull(dataCLang))
                                    {
                                          dataCLang = d.DataCategoryName;
                                    }else{
                                          dataCLang += ', '+ d.DataCategoryName;
                                    }
                              }
                        }
                  }

Again this was not meant to 'Bash' salesforce, rather it is meant to be a guide around the mishaps that I encountered when doing my implementation. But on a side note, because some of the issues are too big to ignore, I am now tasked with re-creating the whole site in .Net :-P 

Questions? 

Twitter: @SalesForceGirl  or Facebook



Friday, March 18, 2011

Queue-Member Edit Customizations

Using the standard Salesforce Queue edit pages for users can be somewhat tiresome, especially if you need to make mass changes. Not to mention that the amount of page refreshes it requires to edit more than 5 users, takes enough time to make you want to throw your keyboard. So to get around that, making a custom page to stream line the process.


 

In my Org I created a User-List page which shows each user by department, in a sortable list/table(more about this & how to make it here) that allows for easy access to a users contact info. From this table, Manager's/TeamLead's/VP's, can click 'edit' next to a user and get taken to a QueueMember edit page witch shows all Queues and whether or not the user belongs to it. Then I also made it so the Edit part of the page was as easy as a click and the page wouldn't need to be refreshed at all.

What this would entail is a userId passed to the page so we know what User we are talking about. Then it would need to get a list of all Queues and then ones only specific for that user. If they wanted to add the user to the Queue, the Queue Id would need to be passed back to the controller, and the same for removing the user from the Queue. Then we update the page with the new User specific Queues and repeat.

public with sharing class QueueMemberEditCon {

      public string sUserName{get;set;}
      public string sQueueId{get;set;}
      public string sUserId{get;set;}
      
      public List<GroupMember> lstFullGroupMemberList{get{returnQueueMemberHelper.FindGroupIdsFromMembers(sUserId);}set;}
      public List<QueueSobject> lstFullQueueList{get{returnQueueMemberHelper.FindQueueList();}set;}
      
      public Pagereference loadUser()
      {
            sUserId = GlobalHelper.UrlParam('UserId');
            if(GlobalHelper.CheckForNotNull(sUserId))
            {
                  sUserName = QueueMemberHelper.UserName(sUserId);
            }
            return null;
      }
      public pageReference RemoveUserFromQueue()
      {
            sQueueId = GlobalHelper.UrlParam('QueueId');
            sUserId = GlobalHelper.UrlParam('UserId');
            if(GlobalHelper.CheckForNotNull(sQueueId) && GlobalHelper.CheckForNotNull(sUserId))
            {
                  QueueMemberHelper.RemoveUserFromQueue(sQueueId, sUserId);
            }
            return null;
      }
      
      public pageReference AddUserToQueue()
      {
            sQueueId = GlobalHelper.UrlParam('QueueId');
            sUserId = GlobalHelper.UrlParam('UserId');
            if(GlobalHelper.CheckForNotNull(sQueueId) && GlobalHelper.CheckForNotNull(sUserId))
            {
                  QueueMemberHelper.AddUserToQueue(sQueueId, sUserId);
            }
            return null;
      }
}

All the work is done in my helper class, but it still only has a few methods in it. The first one retrieves the entire Queue list for the sObject Case, where as the next one Finds the one that are specific to the UserId that is passed. This could technically be split into two methods, but that's up to you. Then of course there is the RemoveUserFromQueue/AddUserToQueue methods which are void but could be booleans so we can base the next steps on whether or not the method was successful, but I decided against it for now. And finally the UserName method which should explain itself. 

public with sharing class QueueMemberHelper {
      /// <summary>
    /// FINDS LIST OF QUEUES UNDER CASE
    /// </summary>
    /// <returns>LIST OF QUEUES</returns>
      public static List<QueueSobject> FindQueueList()
      {
            QueueSobject[] q = [Select Id, QueueId, Queue.Name From QueueSobject whereSobjectType = 'Case' limit 100];
            return q;
      }
      /// <summary>
    /// FINDS USER IN ALL GROUP MEMEBER ITEMS
    /// </summary>
    /// <param name="sInUserId">USER ID</param>
    /// <returns>LIST OF QUEUES</returns>
      public static List<GroupMember> FindGroupIdsFromMembers(string sInUserId)
      {
            if(GlobalHelper.CheckForNotNull(sInUserId))
            {
                  List<string> sList = new List<string>();
                  List<GroupMember> gList = new List<GroupMember>();                  
                  try
                  {
                        GroupMember[] gm = [Select Id, GroupId, Group.Name, UserOrGroupId FromGroupMember Where UserOrGroupId =:sInUserId limit 100];
                        if(GlobalHelper.CheckForNotNull(gm))
                        {
                              for(GroupMember g : gm)
                              {
                                    if(!GlobalHelper.ContainsItem(g.Group.Name, sList))   
                                    {
                                          gList.add(g);
                                    }
                              }
                              return GlobalHelper.AddItemsToList(gList);
                        }
                  }
                  catch(exception e)
                  {
                        system.debug('Oops... QueueMemberHelper - FindGroupIdsFromMember - Error: ' + e);
                        return null;
                  }
            }
            return null;
      }
      /// <summary>
    /// FINDS USER NAME
    /// </summary>
    /// <param name="sInUserId">USER ID</param>
    /// <returns>USER NAME</returns>
      public static string UserName(string sInUserId)
      {
            try
            {
                  User u = [select Name from User where id=:sInUserId limit 1];
                  return u.Name;
            }
            catch(exception e)
            {
                  system.debug('UserInfoListHelper - UserName - error: '+e);
            }     
            return null;
      }
      /// <summary>
    /// REMOVED USER FROM QUEUE MEMBER
    /// </summary>
    /// <param name="sInQueueId">QUEUE ID</param>
    /// <param name="sInUserId">USER ID</param>
      public static void RemoveUserFromQueue(string sInQueueId, string sInUserId)
      {
            try
            {
                  GroupMember gm = [Select Id, GroupId, UserOrGroupId From GroupMember whereUserOrGroupId =:sInUserId and GroupId =:sInQueueId limit 1];
                  if(gm != null)
                  {
                        try
                        {
                              delete gm;
                        }
                        catch(exception e)
                        {
                              system.debug('Oops... QueueMemberHelper - RemoveUserFromQueue - Error: ' + e);
                        }
                  }
            }
            catch(exception e)
            {
                  system.debug('Oops... QueueMemberHelper - RemoveUserFromQueue - Error: ' + e);
            }
            
      }
      /// <summary>
    /// ADDS USER TO QUEUE MEMBER
    /// </summary>
    /// <param name="sInQueueId">QUEUE ID</param>
    /// <param name="sInUserId">USER ID</param>
      public static void AddUserToQueue(string sInQueueId, string sInUserId)
      {
            GroupMember gm = new GroupMember(
                  GroupId = sInQueueId,
                  UserOrGroupId = sInUserId
            );
            try
            {
                  insert gm;
            }
            catch(exception e)
            {
                  system.debug('Oops... QueueMemberHelper - AddUserToQueue - Error: ' + e);
            }
      }
}

So on the Page I used the jQuery table sorter(more info here), and in the apex:repeat parts, you will see I do some 'ninja' to make sure that it displays the correct info. In the apex:repeat I use rendered="{!IF(q.Queue.Name == u.Group.Name, true, false)}" to ensure that it only shows that they have that Queue if the two lists match. Then I use jQuery to Hide or Show the 'Add' or 'Remove' links based on if that field has content.

<apex:page controller="QueueMemberEditCon" standardStylesheets="false" title="Queue Member Edit"action="{!loadUser}">
<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>
<script type="text/javascript" language="javascript" src="{!URLFOR($Resource.UserList, '/js/jquery.tablesorter.min.js')}" ></script>
<apex:form >
<div id="pageWrap" style="width:85%; margin:auto;">
<apex:outputPanel id="TicketListWrap" styleClass="Wrap">
      <script language="javascript" type="text/javascript">
            function Loading(b)
            {
                  if(b){
                        $('.loadingIcon').removeClass('hideIt');
                  }else{
                        $('.loadingIcon').addClass('hideIt');
                  }
            }
            function handleMembers()
            {
                  $('.memberCol').each(function(){
                  if($(this).find('.IsMember').text().trim() == 'Remove')
                  {
                        $(this).find('.IsMember').removeClass('hideMe');
                  }else{
                        $(this).find('.NotMemeber').removeClass('hideMe');
                  }
                });
            }
            $(document).ready(function(){
                  $("table").tablesorter({
                    headers: {
                        0: {sorter: 'text'},
                        1: {sorter: 'text'},
                        2: {sorter: 'text'},
                        3: {sorter: 'text'},
                        4: {sorter: 'text'},
                        5: {sorter: 'text'},
                        6: {sorter: 'text'
                    }
                });
                handleMembers();
            });   
      </script>
      <apex:outputpanel id="queueList">
            <div class="clear"></div>
            <div id="newTicketHeader" class="floatL">
                  Showing Queue's for&nbsp;{!sUserName}
            </div>
            <div class="floatR">
                  <apex:outputlink value="/apex/UserInfoList" onClick="Loading(true);">Back to Users</apex:outputlink>
            </div>
            <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>
            <table id="Usertable" class="list tablesorter" cellspacing="1" style="width:85%; margin:auto;">
            <thead class="rich-table-thead">
                  <tr class="headerRow">
                        <th colspan="1" scope="col">Queues</th>
                        <th colspan="1" scope="col">Is Member</th>
                        <th colspan="1" scope="col">Action</th>
                  </tr>
            </thead>
            <tbody>
                  <apex:repeat value="{!lstFullQueueList}" var="q" id="QueueListRepeater">
                  <tr class="dataRow">
                        <td>{!q.Queue.Name}</td>
                        <td>
                              <apex:repeat value="{!lstFullGroupMemberList}" var="u">
                                    <apex:outputpanel rendered="{!IF(q.Queue.Name == u.Group.Name, true, false)}">
                                          Yes
                                    </apex:outputpanel>
                              </apex:repeat>
                        </td>
                        <td class="memberCol">
                              <div class="hideMe IsMember">
                              <apex:repeat value="{!lstFullGroupMemberList}" var="u">
                                    <apex:outputpanel rendered="{!IF(q.Queue.Name == u.Group.Name, true, false)}">
                                          <apex:CommandLink value="Remove"onClick="Loading(true);" action="{!RemoveUserFromQueue}" rerender="TicketListWrap"oncomplete="Loading(false);">
                                                <apex:param name="QueueId" value="{!q.QueueId}"/>
                                          </apex:CommandLink>
                                    </apex:outputpanel>
                              </apex:repeat>
                              </div>
                              <div class="hideMe NotMemeber">
                                    <apex:CommandLink value="Add" onClick="Loading(true);"action="{!AddUserToQueue}" rerender="TicketListWrap" oncomplete="Loading(false);">
                                          <apex:param name="QueueId" value="{!q.QueueId}"/>
                                    </apex:CommandLink>
                              </div>
                        </td>
                  </tr>
                  </apex:repeat>
            </tbody></table>
      </apex:outputpanel>
      </apex:outputpanel>
</div>
</apex:form>
</apex:page>

Questions? 
Twitter: @SalesForceGirl  or Facebook