måndag 22 februari 2016

Having knowledge public with Version 3

As some of you may have noticed, upgrading to knowledge version 3 made a big impact by making it impossible to have the a knowledge base "real" public. What I mean with real public is that user can use the knowledge base without having to log in first. This was possible in version 2, but right now in version 3, the user need to be logged in, even if the user doesn't have any roles.

This was a critical problem for us since we had a deadline to migrate our current knowledge base into Servicenow. Here is the solution.



Before I begin to explain how I did this, I want to point out that there is a problem record at ServiceNow about this and hopefully we will be able to use that solution instead when it becomes available. You can read about that here: PRB633975.


The result of our "workaround" is a dropdown menu with the top- & subcategories and a search function.

This is the criteria I've been working after:

  1. We only have 2 category levels, 1 top menu and 1 submenu. But it isn't hard to change the code to have more than 1 layer of submenu if that is needed.
  2. The start page should be a list over "recommended" articles that we choose. Pretty similar to the feature content in knowledge v3
  3. The user should be able to search for articles.
  4. All of the above should be working without the user being logged in and should only get data from our "external" knowledge base.

A quick summary of how it is build together.


  • CMS Page "knowledge": The Page that holds both the menu and the search function.
  • Dynamic Block "KB menu": This page contains the dropdown menu and is created with angular and will dynamically update the top- & submenu when new category is added inside ServiceNow.
  • UI Page kb_find.do: I modified the OOB "kb_find.do" that exists and isn't used anymore in Knowledge v3. I'm  using this to get the OOB functionality of searching for articles.
  • UI Page kb_view.do: Same as above, using the OOB "kb_view.do" to be able to view the articles. Just be careful here, since V3 also uses this UI Page to display the articles.
  • Style sheets: I got 3 style sheets to keep the stuff in order. 1 for the menu, 1 for the kb_find and 1 for kb_view. Remember to replace the sys_id for the style sheets in the links so it matches your own sys_ids.
  • Script Include "customKnowledgeSearch": Using this to get all categories that I need and also to sort out the top menus from submenu.
  • New fields: Created a new field on the kb_category records to handle the parent/child relationship. Since the field that exist oob is of type "Document ID" I can't dot walk when I do queries. I also created a field on articles to handle the recommended start page.
  • Business Rule "Populate parent reference": This BR populates the custom field on kb_category so new categories will show up in the menu.
  • UI Script "knowledgemenu": This script handles all the angular controller code etc.
  • System properties: I set this to true so I also see the category in the lists over search results: glide.knowman.search.show_category

Here we go:

1. CMS Page "Knowledge"

This page isn't so much to say about. It has 2 things in it. on top there is a dynamic block "KB menu" which holds the dropdown menu. Below is an iFrame which points to the kb_find.do and it's named "kb_home". Since we want to have the recommended articles showing when I get to the page I use this url: kb_find.do?sysparm_rec=true. The sysparm_rec=true is handled inside the kb_find.do.

2. Dynamic block "KB menu"

This dynamic block doesn't need much code. Thanks to Angular I can keep it pretty simple. The links points to target="kb_home" which is the iFrame where kb_find.do lives.


<?xml version="1.0" encoding="utf-8" ?>
<j:jelly trim="false" xmlns:j="jelly:core" xmlns:g="glide" xmlns:j2="null" xmlns:g2="null">
<!--Import the needed libraries -->
    <g:requires name="angular.min.jsdbx" />
<g:requires name="jquery.min.jsdbx" />
<g:requires name="bootstrap.min.jsdbx" />
<g:requires name="knowledgemenu.jsdbx" />

<!-- Create the dropdown menu -->
<nav ng-app="KB_custom" ng-controller="MainController">
<ul>
<li  ng-repeat="x in displayCategories('top') | orderBy: 'label'"><a target="kb_home" href="kb_find.do?sysparm_category={{x.sys_id}}">{{x.label}}</a>
<ul>
<li ng-repeat="submenu in displayCategories(x.sys_id)"><a target="kb_home" href="kb_find.do?sysparm_category={{submenu.sys_id}}">{{submenu.label}}</a></li>
</ul>
</li>
</ul>
</nav>

</j:jelly>

3. UI Page kb_find.do

this is the UI Page that knowledge v2 used to search for articles as I understand it. I can't find any place in version 3 that uses it. I have taken a useful script that I found on the wiki and modified it to fit our criteria. I have made it check for two parameters that I use. sysparm_rec uses to tell kb_find that it should only find articles that has the recommended field set to true. And this is what I use to point out which articles to show on the first page. The other one is sysparm_category which I uses to show articles belonging to a specific category which they have clicked on the dropdown menu. If none of these parameters appear in the url, then the user has "just" searched with the search-field.

I also added the sys_id of our external knowledge base to the query. This to stop the search to only search within the articles of that knowledge base.

In the end you can see the link to the style sheet, and I put it there to be sure it override whatever come from the xml-files it includes which I cannot edit...

I try to comment in the code, but if there is anything you wonder about, just tell me and I'll explain.

<?xml version="1.0" encoding="utf-8" ?>
<!--
 Knowledge management v2 - list viewer 
original from: http://wiki.servicenow.com/index.php?title=Useful_Task_Scripts#gsc.tab=0
Modified by Göran Lundqvist
-->
<j:jelly trim="false" xmlns:j="jelly:core" xmlns:g="glide" xmlns:j2="null" xmlns:g2="null">
<link href="kb_styles.cssx" type="text/css" rel="stylesheet" />
 <SCRIPT LANGUAGE="JavaScript" SRC="kb.jsx?v=${gs.getProperty('glide.builddate')}" />

 <g2:evaluate jelly="true" var="jvar_item">
 var kb = new GlideRecord('kb_knowledge');
  
 var operator = 'IR_AND_QUERY';
 if (jelly.sysparm_operator) 
 operator = jelly.sysparm_operator;
 if (jelly.sysparm_search $[AND] jelly.sysparm_search.length != 0)
 kb.addQuery(operator, jelly.sysparm_search);
<!--Check if sysparm_rec is in url and add to search query if it exist -->  
if (jelly.sysparm_rec $[AND] jelly.sysparm_rec.length != 0 $[AND] !jelly.sysparm_search)
 kb.addQuery('u_recommended', jelly.sysparm_rec);  
<!--Check if the sysparm_category is in url(clicked on a dropdown menu) and put that in the search -->
 if (jelly.sysparm_category $[AND] jelly.sysparm_category.length != 0) {
  var kbor = kb.addQuery('kb_category', jelly.sysparm_category);
kbor.addOrCondition('kb_category.parent_id', jelly.sysparm_category);  
 }
<!--Add the sysid of our external KB. This is it only fetch data from that KB -->  
var kbsys_id = '1708e80737941e00b8a0303643990ec4'; //Set the sys_id of the external KB
 kb.addQuery('active', 'true');
kb.addQuery('kb_knowledge_base', kbsys_id);
 kb.addQuery('workflow_state', 'published');
 var orderField = 'relevancy';
 var sortSequence = gs.getProperty('glide.knowman.order.search');
 if (sortSequence != 'relevancy') {
   kb.orderByDesc('sys_view_count');
   orderField = 'view count';
 }
 kb.query();
 </g2:evaluate>
 <table class="wide" cellspacing="0" border="0" style="margin-bottom: 8px;">
 <tr>
 <td class="title" nowrap="true" colspan="99">
<g:inline template="kb_header.xml" />
 </td>
 </tr>
 </table>
 <table class="wide" cellspacing="0" border="0" style="margin-bottom: 8px;">
 <tr class="header" border="0" cellspacing="0">
 <td class="column_head" colspan="3">
 <table border="0" cellspacing="0" cellpadding="0">
 <tr class="header" border="0" cellspacing="0">
 <form method="GET" action="${sysparm_base_form}.do" name="${sysparm_base_form}.do">
 <input type="HIDDEN" name="sys_action" value="none" />
 <g2:emitParms suppress="sysparm_this_url_enc" />
 <input type="HIDDEN" name="sysparm_modify_check" value="true" />
 <td>
 <input type="HIDDEN" id="sysverb_back" />
 <img name="not_important" 
 value="sysverb_back" 
 id="sysverb_back" 
 onClick="return gsftSubmit(document.getElementById('sysverb_back'));" 
 src="images/green_back.gifx" 
 title="${gs.getMessage('Back')}" 
 alt="${gs.getMessage('Back')}" 
 style="cursor:hand; margin-left: 4px;"/>
 </td>
 </form>
 <td>
 <div class="caption" style="margin-top: 2px;">
   $[gs.getMessage('search results')]
   ($[gs.getMessage('sorted by')] $[gs.getMessage(orderField)])
 </div>
 </td>
 </tr>

 </table>
 <!--If it's the page showing recommended, add this title -->
<j2:if test="$[sysparm_rec]">
<h2>$[SP]Recommended</h2>  
</j2:if>  
</td>
 </tr>

 <j2:set var="jvar_printed_some" value="false"/>
  <g2:inline template="kb_search_results.xml"/>
<j2:if test="$[jvar_printed_some == false]">
<g2:evaluate var="jvar_item" expression="gs.flushMessages();"/>
<tr>
<td>
  <div class="kb_no_text">Your search - <span class="kb_stand_out">$[sysparm_search]</span> - did not match any documents.</div>
</td>
</tr>
</j2:if> 
<tr border="0" cellspacing="0"><td><br /></td></tr> 
 </table>
<!-- Add style sheet to remove some elements that are fetched from the xml-files -->
<link href="f984761d0f699a001314715ce1050e7a.cssdbx" rel="stylesheet" type="text/css"></link>
</j:jelly>

4. UI Page kb_view.do

Here I only added a small thing in the end. Since both Version 2 & 3 uses this, I don't wanna change to much. And we will be using the Version for the "inhouse" knowledge base, so to make our changes only work on the external knowledge base I added a style sheet in the end with some conditions on when it should be loaded.

<?xml version="1.0" encoding="utf-8" ?>
<j:jelly trim="false" xmlns:j="jelly:core" xmlns:g="glide" xmlns:j2="null" xmlns:g2="null">
<j2:set var="jvar_hide_response_time" value="true" />
<g:inline template="ie_checker.xml" />
<g:requires name="scripts/lib/jquery_includes.js"/>
<g:requires name="styles/knowledge.ng/css_includes_knowledge.css" includes="true"/>

<j2:if test="$[!jvar_isMSIE7]">
<j2:if test="$[!jvar_isMSIE8]">
<g:requires name="scripts/classes/timeAgo.js"/>
</j2:if>
</j2:if>

<!-- Common styles are loaded in the "Knowledge Common Styles" stylesheet -->
<link href="b3ba3821d73221004792a1737e610382.cssdbx?v=${gs.getProperty('glide.builddate')}" type="text/css" rel="stylesheet"/>

<g:evaluate jelly="true" copyToRhino="true">
var uiMacro = "kb_view_legacy";
var kbViewModel = new KBViewModel();
var kbViewInfo = kbViewModel;
kbViewModel.getInfo();
var canContributeHelper = new SNC.KnowledgeHelper();
var knowledgeHelp = new KnowledgeHelp();
var historyRowCount = 0;
var isValidRecord = kbViewModel.isValid;
var knowledgeExists = kbViewModel.knowledgeExists;
var NOT_RETIRED = gs.getMessage("Article not retired");
var NOT_PUBLISHED = gs.getMessage("Article not published");
var NOT_SAVED = gs.getMessage("Article not saved");
var SAVED = gs.getMessage("Article saved");
var DISCARDED = gs.getMessage("Article changes discarded");
var SUBMITTED = gs.getMessage("Your article has been submitted");
var PREVIEW = gs.getMessage(" Preview ");
var PREVIEW_HINT = gs.getMessage("Preview changes");
var DELETE = gs.getMessage("Delete");
var CONFIRM_DELETE = gs.getMessage("Confirm deletion of this article and all its revisions?");
var TITLE_CANCEL = gs.getMessage("Cancel changes");
var MESSAGE_CANCEL = gs.getMessage("Discard all changes?");
var TITLE_RETIRE = gs.getMessage("Retire");
var MESSAGE_RETIRE = gs.getMessage("Retire this article?");
// Status messages for the message bar.
var DRAFT_MSG = gs.getMessage("This knowledge item has been created");
var REVIEW_MSG = gs.getMessage("This knowledge item has been published");
var PUBLISHED_MSG = gs.getMessage("This knowledge item has been published");
var PEND_RETIRE_MSG = gs.getMessage("This knowledge item has been retired");
var RETIRED_MSG = gs.getMessage("This knowledge item has been retired");
var DELETE_FAIL_MSG = gs.getMessage("This article could not be deleted");
var TXT_PLACEHOLDER = gs.getMessage("Add content");

if (isValidRecord) {
var knowledgeRecord = kbViewModel.knowledgeRecord;
var canContributeToKnowledge = canContributeHelper.canContribute(kbViewModel.knowledgeRecord);
var publishedRecord = kbViewModel.publishedRecord;
var feedbackRecords = kbViewModel.feedbackRecord;
var bannerImage = kbViewModel.bannerImage;
var authorImage = kbViewModel.authorImage;
var authorName = kbViewModel.authorName;
var authorCompany = kbViewModel.authorCompany || kbViewModel.getAuthorInfo("author.company.name");
var authorDepartment = kbViewModel.authorDepartment || kbViewModel.getAuthorInfo("author.department.name");
var authorTitle = kbViewModel.authorTitle || kbViewModel.getAuthorInfo("author.title");
var i18n = function(message, array) {
message = message || "";
var padded = " " + message + " ";
var translated = gs.getMessage(padded, array);
var trimmed = translated.trim();
return trimmed;
};

var canCreateNew = kbViewModel.canCreateNew;
var isNewRecord = kbViewModel.isNewRecord;
var published = "";
var sys_updated_on = "";
if (kbViewModel.publishedRecord) {
published = kbViewModel.publishedRecord.published;
sys_updated_on = kbViewModel.publishedRecord.sys_updated_on;
}
var number = knowledgeRecord.number;
var permalink = kbViewModel.permalink;
var category = knowledgeRecord.category;
var attachments = kbViewModel.attachments;
var disableSuggesting = knowledgeRecord.disable_suggesting;
var glideWikiModel = new GlideWikiModel();
glideWikiModel.setLinkBaseURL(glideWikiModel.getLinkBaseURL() + "${AMP}sysparm_field=kb_knowledge.wiki" + "${AMP}sysparm_kbtable=" + kbViewInfo.tableName);
var relatedContent = kbViewInfo.relatedContent || kbViewInfo.getCurrentRelatedContent();
}

var isVersion2 = new KBCommon().isKBVersion2(knowledgeRecord.kb_knowledge_base);
if (isVersion2) {
if (jelly.sysparm_context == 'popup')
uiMacro = "kb_view_legacy_popup";
else
uiMacro = "kb_view_legacy";
}
</g:evaluate>

<script>
var kbConfig = {
canContribute: '${canContributeToKnowledge}',
historyRowCount: ${historyRowCount},
i18n: {
STATUS_MSG: {
draft: '${JS:DRAFT_MSG}',
review: '${JS:REVIEW_MSG}',
published: '${JS:PUBLISHED_MSG}',
pending_retirement: '${JS:PEND_RETIRE_MSG}',
retired: '${JS:RETIRED_MSG}',
delete_failed: '${JS:DELETE_FAIL_MSG}'
},
TXT_PLACEHOLDER: '${JS:TXT_PLACEHOLDER}',
NOT_RETIRED: '${JS:NOT_RETIRED}',
NOT_PUBLISHED: '${JS:NOT_PUBLISHED}',
NOT_SAVED: '${JS:NOT_SAVED}',
SAVED: '${JS:SAVED}',
DISCARDED: '${JS:DISCARDED}',
SUBMITTED: '${JS:SUBMITTED}',
PREVIEW: '${JS:PREVIEW}',
PREVIEW_HINT: '${JS:PREVIEW_HINT}',
CONFIRM_DELETE: '${JS:CONFIRM_DELETE}',
DELETE: '${JS:DELETE}',
TITLE_RETIRE: '${JS:TITLE_RETIRE}',
MESSAGE_RETIRE: '${JS:MESSAGE_RETIRE}',
TITLE_CANCEL: '${JS:TITLE_CANCEL}',
MESSAGE_CANCEL: '${JS:MESSAGE_CANCEL}'
}
};

$j(function() {
if (parent.document) {
// fix iframe resize issue for CMS service portal
parent.CustomEvent.fire('content_frame.resized', window.name, parent.document.body.scrollHeight);
}
});
</script>

<j:choose>
<j:when test="${isVersion2}">
<g:inline template="${uiMacro}"/>
</j:when>
<j:when test="${isValidRecord &amp;&amp; knowledgeHelp.hasRights(knowledgeRecord)}">
<g:inline template="kb_view_common" />
</j:when>
<j:when test="${knowledgeExists}">
<g:inline template="kb_view_cannot_read"/>
</j:when>
<j:otherwise>
<g:inline template="kb_view_not_valid_record"/>
</j:otherwise>
</j:choose>
<!--Check if the user has no roles and if that is the case, put on the stylesheet to hide some elements -->
<j:set var="jvar_user_roles" value="${gs.getUser().getRoles()}"/>
<j:if test="${empty(jvar_user_roles)}">
<link href="d918ddd20f25da001314715ce1050e15.cssdbx" rel="stylesheet" type="text/css"></link>
</j:if>
</j:jelly>

5. Style sheets

Dropdown menu:

The whole dropdown menu is build with css. Here is the code for that and some colors etc.

/** Menu setup from http://line25.com/tutorials/how-to-create-a-pure-css-dropdown-menu **/

nav ul {
background: #cfe2d7;
padding: 0px 0px;
list-style: none;
position: relative;
display: flex;
width: 100%;
border: 1px solid #b0d0bd;
}
nav ul::after {
content: ""; 
clear: both; 
display: block;
}
nav ul li:hover > ul {
display: block;
color: #333 !important;
}
nav ul li:hover {
background: #fff;
font-weight: bold;
}
nav ul ul li:hover {
background: #b0d0bd;
font-weight: bold;
}
nav ul li a:hover {
color: #333 !important;
}
nav ul li {
float: left;
flex: auto;
}
nav ul li a {
display: block;
padding: 15px 20px;
text-decoration: none !important;
}
nav ul ul {
background: #e1efe6;
border: 1px solid #b0d0bd;  
border-radius: 3px;
padding: 0;
position: absolute;  
top: 100%;
display: none;
width: auto;
}
nav ul ul li {
float: none; 
position: relative;
border-radius: 3px;
font-weight: initial;
}
nav ul ul li a {
color: #333 !important;
}

kb_find:


/** Hides various buttons and fields on KB_find.do which come from the kb_header.xml ui macro**/


.obvious, .kb_btn_container, #kb_v3_vcr, #menuButton, .kb-btn-advanced {
display:none !important;
}
.wide table tbody tr > td {
color: #fff !important;
}

kb_view:

/** Hides various buttons and fields on KB_view.do which come from the OOB xml files we can't edit.**/

.kb_view_breadcrumb, .snc-article-header-author, .kb-user-feedback, .snc-comments-live-feed-cntnr, #flagArticle {
display:none !important;
}


6. Script include "customKnowledgeSearch"

This script include has 2 methods. One to get all the categories for the top menu and one to take care of the submenu. This is designed for only having one submenu, but isn't hard to expand it deeper down if you want to.

var customKnowledgeSearch = Class.create();
customKnowledgeSearch.prototype = Object.extendsObject(AbstractAjaxProcessor, {
//Function that angular calls to get all records that shall be shown on active tab
getCategories: function() {

//Variable that will contain all the objects that we find
var kbCat = [];
//Create query to fetch the top categories from my "public knowledgebase";
var encquery = 'parent_id=1708e80737941e00b8a0303643990ec4^ORu_parent_reference.parent_id=1708e80737941e00b8a0303643990ec4';
var gr = new GlideRecord('kb_category');
gr.addEncodedQuery(encquery);
gr.query();

//Go through all records and take those fields that I want and put them in a variable that I push into kbCatTop variable.
while(gr.next()) {
var svar = {};
svar.label = gr.label.toString();
svar.sys_id = gr.sys_id.toString();
svar.u_parent_reference = gr.u_parent_reference.toString();
kbCat.push(svar);
}
//After we are done with all the records we throw in JSON and send it back.
return new global.JSON().encodeArray(kbCat);
},

//Function to get the submenus of The main category
getSubmenuTop: function() {
//Variable to contain the submenu of the topmenu
var kbSubmenuTop = [];
var catsys_id = this.getParameter('sysparm_topSysid');
var gr = new GlideRecord('kb_category');
gr.addQuery('parent_id',catsys_id);
gr.query();

while(gr.next()) {
var svar = {};
svar.label = gr.label.toString();
svar.sys_id = gr.sys_id.toString();
kbSubmenuTop.push(svar);
}
return new global.JSON().encodeArray(kbSubmenuTop);
},
//This we need to have otherwise this script include don't work on public UI Pages.
isPublic: function() {
return true;
}
});

7. New fields

I created two field.

1. u_parent_reference on the kb_category table. This pretty much show the same as the parent_id. Problem with parent_id is that it's type is "document id" and it isn't possible to dotwalk with those fields. With this new field which is a reference to kb_category and I can happily dotwalk. So this is needed for my script include to work. This field needs to be manually populated either by a background script och just editing the fields on the categories that already exists. New ones will the business rule take care off. the top categories don't have this field populated because they don't belong to any parent category.

2. u_recommended is on kb_knowledge and can be set on articles so they show up on the "start page". See this as about the same thing as featured content in v3.


8. Business Rule "populate parent reference"

This BR is used to populate the field u_parent_reference if someone creates a new category after this has been implemented. This also needs to be modified if you have more than one level of submenu.

function onBefore(current, previous) {
 //This is the Sys_id for our "external knowledgebase"
var kbExternalSysID = '1708e80737941e00b8a0303643990ec4';

//Get the parent_id record of the new category that is being created
var gr = new GlideRecord('kb_category');
gr.get(current.parent_id);

//If its parent is connected to the external knowledgebase
if (gr.parent_id == kbExternalSysID)
//Fill in our own filter so it works in our portal
current.u_parent_reference = current.parent_id;
}



9. UI Script "knowledgemenu"

This is the angular controller and it keeps the data from the script include and put it into variables for the dropdown menu to use.

var myApp = angular.module('KB_custom', []);
myApp.controller('MainController', ['$scope', function($scope) {
$scope.getCategory = function () {
var ga = new GlideAjax('customKnowledgeSearch');
ga.addParam('sysparm_name','getCategories');
ga.getXML(handleCategories);
function handleCategories(response) {
var answer = response.responseXML.documentElement.getAttribute('answer');
$scope.$apply(function () {
$scope.Categories = angular.fromJson(answer);
});
}
};
$scope.getCategory();

$scope.displayCategories = function (catsysid) {

var menulvl = catsysid;
var result = [];
if (menulvl == 'top') {
for(i=0; i < $scope.Categories.length; i++) {
if(!$scope.Categories[i].u_parent_reference){
result.push($scope.Categories[i]);

}
}
}
else {
for(i=0; i < $scope.Categories.length; i++) {
if($scope.Categories[i].u_parent_reference == catsysid){
result.push($scope.Categories[i]);
}
}
}
return result;
}
}]);


10. Others

Well, the last things are just to put both kb_view and kb_find on the sys_public list. They probably already are there, but might be set to "false". In that case change it to "true".

Hope this can help you while waiting for the fix on version 3 to be "real public".

//Göran

2 kommentarer: