Showing posts with label XsltListViewWebPart. Show all posts
Showing posts with label XsltListViewWebPart. Show all posts

Sunday, January 20, 2019

Tip: how-to resolve in SharePoint 2016, XsltListViewWebPart displaying Calculated Fields as HTML-encoded

As of June 2017, Microsoft made a change to explicit block in XlstListViewWebPart the rendering of custom HTML markup in Calculated Fields: Handling HTML markup in SharePoint calculated fields: "Some users have added HTML markup or script elements to calculated fields. This is an undocumented use of the feature, and we will block the execution of custom markup in calculated fields in SharePoint Online from June 13, 2017 onwards. The June 2017 Public Update (PU) and later PUs will make blocking a configurable option for on-premises use in SharePoint Server 2016 and SharePoint Server 2013." In the article, Microsoft states that for SharePoint on-premises (2013 version and later) one can restore to the previous behavior through a new property on webapplication level: CustomMarkupInCalculatedFieldDisabled. For SharePoint Online there is no such configuration option provided.
A bit by coincidence (and also by mere perseverance 😉), I detected an alternative - undocumented - approach that does not require an overall webapplication setting. And as bonus, likely also works in SPO context. Default the XslLink property in XsltListViewWebPart XmlDefinition is configured via the View property: SPView.XslLink. The essence of the workaround / approach is to make use of the Xsl-behavior in the superclass of XsltListViewWebPart: DataFormWebPart. If you specify Xslt transformation at that level (either via XslLink, or via Xsl property), then you can configure for single XsltListViewWebPart instance that custom HTML markup of Calculated Field is still executed.
Although undocumented, it is an own Microsoft source that helped me discovering this => the SharePoint Escalation Service Team Blog - Guidance for XslLink property with the XsltListViewWebPart: "There exist other alternatives to let the XsltListViewWebPart use your XSL Transform. "
Note also that when you used 'Design' mode in SharePoint Designer 2010 to customize the Xslt-rendering of a list column, the customized Xslt-template is also injected in the Xsl property on webpart level.
My findings clarified through pictures:

At first the issue: custom HTML markup of Calculated Field is rendered as html-encoded

Move in the XMLDefinition the location of XslLink from 'View' to 'WebPart' level

And the rendering of custom HTML markup is restored, as we know it from before

Thing to be aware of: with using the 'XslLink' property it is a prerequisite that the applied Xslt stylesheet file is located in the layouts folder (see: Guidance for XslLink property with the XsltListViewWebPart: "however you would need to place your XSL file in the default “_layouts/xsl” folder"). In the above scenario that is guaranteed as I simply refer to the standard 'main.xsl' stylesheet that is by Microsoft itself provisioned in the layouts folder, both on-premises as online. If however you need a deviation from the standard XsltListViewWebPart rendering, then you need to provision your own custom Xsl stylesheet thus also in the layouts folder. Which in SharePoint Online context is of course not possible. If the deviation is only minimal, e.g. to override the standard rendering for only one column, then I advise to make use of the 'Xsl' property instead, and only include in it the overloading 'xsl:template'.
Example applied to scenario of above
<xsl>
   <xsl:stylesheet xmlns:x="http://www.w3.org/2001/XMLSchema" 
      xmlns:d="http://schemas.microsoft.com/sharepoint/dsp" 
      version="1.0" 
      exclude-result-prefixes="xsl msxsl ddwrt" 
      xmlns:ddwrt="http://schemas.microsoft.com/WebParts/v2/DataView/runtime" 
      xmlns:asp="http://schemas.microsoft.com/ASPNET/20" 
      xmlns:__designer="http://schemas.microsoft.com/WebParts/v2/DataView/designer" 
      xmlns:xsl="http://www.w3.org/1999/XSL/Transform" 
      xmlns:msxsl="urn:schemas-microsoft-com:xslt" 
      xmlns:SharePoint="Microsoft.SharePoint.WebControls" 
      xmlns:ddwrt2="urn:frontpage:internal" 
      xmlns:o="urn:schemas-microsoft-com:office:office">
         <xsl:include href="/_layouts/15/xsl/main.xsl"/>
         <xsl:include href="/_layouts/15/xsl/internal.xsl"/>
         <xsl:param name="AllRows" select="/dsQueryResponse/Rows/Row[$EntityName = '' or (position() >= $FirstRow and position() <= $LastRow)]"/>
         <xsl:param name="dvt_apos">'</xsl:param>                                                      
         <xsl:template match="FieldRef[@Name='Folder']" mode="Number_body">
            <xsl:param name="thisNode" select="."/>
            <b><i>
            <xsl:value-of disable-output-escaping="yes" select="$thisNode/@*[name()=current()/@Name]" />
            </i></b>
         </xsl:template>
   </xsl:stylesheet>
</xsl>

Saturday, January 19, 2019

Tip: how-to easily expose the underlying XML datastructure retrieved in XsltListViewWebPart

If for what ever reason you want to inspect the XML structure or content of underlying data source retrieved from a SharePoint List/Library before it is rendered via Xslt-transformation, you can do that as follows:
  • Open SharePoint Designer 2013 (or 2010 version in case you need it in older SharePoint 2010 context)
  • Open the page on which the XsltListViewWebPart is included (list View, SitePage)
  • Select to edit the page in raw HTML mode, and locate the 'XsltListViewWebPart' node in the HTML
  • Inject the below Xslt-snippet as value for 'Xsl' property of the XsltListViewWebPart definition:
    <xsl>
       <xsl:stylesheet xmlns:x="http://www.w3.org/2001/XMLSchema" 
          xmlns:d="http://schemas.microsoft.com/sharepoint/dsp" 
          version="1.0" 
          exclude-result-prefixes="xsl msxsl ddwrt" 
          xmlns:ddwrt="http://schemas.microsoft.com/WebParts/v2/DataView/runtime" 
          xmlns:asp="http://schemas.microsoft.com/ASPNET/20" 
          xmlns:__designer="http://schemas.microsoft.com/WebParts/v2/DataView/designer" 
          xmlns:xsl="http://www.w3.org/1999/XSL/Transform" 
          xmlns:msxsl="urn:schemas-microsoft-com:xslt" 
          xmlns:SharePoint="Microsoft.SharePoint.WebControls" 
          xmlns:ddwrt2="urn:frontpage:internal" 
          xmlns:o="urn:schemas-microsoft-com:office:office">
             <xsl:output method="xml" indent="yes" />
             <xsl:template match="/">
                <xmp>
                  <xsl:copy-of select="*"/>
                </xmp>
             </xsl:template>
       </xsl:stylesheet>
    </xsl>
    
  • Save the page in SharePoint Designer
  • Open or refresh the page in browser; if the underlying XML not immediate displayed (XsltListViewWebPart applies caching in it's Xslt pipeline), reload once more
  • And you are able to inspect in browser the underlying XML data island structure retrieved by the query applied in the XsltListViewWebPart instance:

Friday, December 9, 2016

Freeze pane on SharePoint list with responsive scroll height

Earlier I blogged on how-to 'Freeze pane on SharePoint list': augment the standard SharePoint ListView UI to fixate the list header in visible sight when the user scrolls down the list in the browser. The user was totally happy with this delivered solution..., until opening the page on smaller screen (estate)... In the original code, the browser scrollbar is explicit hidden. An non-usability effect is that the user cannot scroll to the lower part in case the configured height of the 'scrollable listview' extends beyond the physical browser height. On the first coding, I made the explicit decision to hide the browser scrollbar via CSS, to avoid double scrollbars: without the CSS "overflow:hidden" property on 's4-workspace', the browser will always render its own scrollbar for pages that initial extend the browser height, even when later via clientside coding the page height is on-the-fly reduced to fit in browser height. The presence of double scrollbars - browser + on the list - is both confusing as non-esthetic. I prevented this via the 'overflow:hidden'.
However, in the initial code I didn't accompensate sufficient for smaller physical screens. I re-evaluated the 'freeze' code, and made some adjustments to make it responsive to the physical screen height. Another fix is to duplicate eventhandling on the standard listview header to the frozen header, for filtering, and 'select/deselect all' functionalities. Yet another fix is in the positioning of the ECB menu: default this is positioned relative to the listview top, which however itself is floating due the 'freeze' handling.
Updated code:
var visibleTableHeight = -1; // Freeze the header of listview. function FreezePane() { var $table = $(".ms-listviewtable").first().data("summary", "<listview name>"); var freezedTRHeight = 25; // Determine the available height for table to render in visible screen. if (visibleTableHeight === -1) { var origWorkspaceHeight = $("#s4-workspace").height(); var origTableHeight = $table.height(); var spaceInWorkspaceWithoutTable = origWorkspaceHeight - origTableHeight; var windowHeight = window.innerHeight; if (windowHeight == undefined) { windowHeight = document.documentElement.clientHeight; } var topPos = $("#s4-workspace").offset().top; var availableHeight = windowHeight - topPos - spaceInWorkspaceWithoutTable - freezedTRHeight; // Set visible height, with minimum of 300 visibleTableHeight = Math.max(availableHeight, 300); } // WRAP TABLE IN SCROLL PANE $("#s4-workspace").css( { 'overflow-y': 'auto' } ); $table.wrap("
"); // FROZEN HEADER ROW $(".FreezedLV").wrap("<DIV class='FreezedLVContainer' style='position:relative; margin:0px; height:" + (visibleTableHeight + freezedTRHeight) + "px;'></DIV>"); $("<table id='FreezedTR' class='ms-listviewtable' cellPadding='1' cellSpacing='0'></table>").insertBefore(".FreezedLV"); $("#FreezedTR").width($table.width() + "px"); var $origHeader = $("TR.ms-viewheadertr:first", $table); var $firstRowTable = $("TR.ms-itmhover:first", $table); var $freezeHeader = $origHeader.clone(); // Propagate computed width of columns of origheader to freezeheader $origHeader.children("th").each(function() { var width = $(this).width(); var ownerIndex = $(this).index(); $($freezeHeader.children("th")[ownerIndex]).width(width + "px"); }); var $alignerRow = $firstRowTable.clone(); $alignerRow.css( { 'visibility' : 'hidden' } ); $("#FreezedTR").append($alignerRow); $("#FreezedTR").wrap("<DIV style='HEIGHT: " + freezedTRHeight + "px;'></DIV>"); // Visual "hide" the orig header, make sure it still is rendered as otherwise the alignment of 'body' rows is altered $origHeader.children("th").each(function() { $(this).css( { 'height' : '0px' , 'max-height' : '0px', 'min-height' : '0px' , 'padding-top' : '0px', 'padding-bottom' : '0px' } ); $(this).find("div").css( { 'height' : '0px' , 'max-height' : '0px', 'min-height' : '0px', 'margin-top' : '0px', 'margin-bottom' : '0px' } ); $(this).find("input").css( { 'height' : '0px' , 'max-height' : '0px', 'min-height' : '0px' } ); }); $origHeader.css( { 'max-height' : '1px', 'visibility' : 'hidden' } ); // Delegate eventhandlers from the copied+freezed header to the actual header of the listview. var $inputFH = $freezeHeader.find("input[title='Select or deselect all items']"); $inputFH[0].onclick = null; $inputFH[0].onfocus = null; $table.onmouseover = null; $inputFH.click(function() { $(this).closest(".FreezedLVContainer").find(".FreezedLV .ms-viewheadertr").find("input[title='Select or deselect all items']").trigger('click'); }); $inputFH.focus(function() { $(this).closest(".FreezedLVContainer").find(".FreezedLV .ms-viewheadertr").find("input[title='Select or deselect all items']").trigger('focus'); }); $("#FreezedTR").mouseover(function() { $(this).closest(".FreezedLVContainer").find(".FreezedLV .ms-listviewtable").trigger('mouseover'); }); ExecuteOrDelayUntilScriptLoaded(OverloadPositionCtxImg, "core.js"); Overload__doPostBack(); } // Need to update/overload positioning of 'ECB' icon as SharePoint standard it is relative positioned towards its direct parent; // and due scrolling would become invisible. function OverloadPositionCtxImg() { if ($.prototype.KI_base_PositionCtxImg === undefined) { $.prototype.KI_base_PositionCtxImg = PositionCtxImg; PositionCtxImg = function(c, b, h) { $.prototype.KI_base_PositionCtxImg(c, b, h); var a = c.style; a.top = (c.parentNode.offsetTop + b.clientTop) + "px"; }; } } // Overload MicrosoftAjax UpdatePanel function, to restore FreezePane after navigating to another page in the overview. function Overload_updatePanel() { if (Sys.WebForms.PageRequestManager.prototype.KI_base__updatePanel === undefined) { Sys.WebForms.PageRequestManager.prototype.KI_base__updatePanel = Sys.WebForms.PageRequestManager.prototype._updatePanel; Sys.WebForms.PageRequestManager.prototype._updatePanel = function(updatePanelElement, rendering) { Sys.WebForms.PageRequestManager.prototype.KI_base__updatePanel(updatePanelElement, rendering); if (updatePanelElement === $(".ms-listviewtable").first().data("summary", "<listview name>").closest("div")[0]) { if (("td.ms-addnew").length > 1) $("td.ms-addnew").last().closest("table").hide() FreezePane(); } }; } } function Overload__doPostBack() { if ($.prototype.KI_base__doPostBack === undefined) { $.prototype.KI_base__doPostBack = __doPostBack; __doPostBack = function (eventTarget, eventArgument) { // Make sure to overload the updatePanel function before postback from navigate button. if ($.prototype.KI_base__updatePanel === undefined) Overload_updatePanel(); $.prototype.KI_base__doPostBack(eventTarget, eventArgument); } } }

Friday, October 7, 2016

On-the-fly extend ECB-menu

Say you have business data administrated in a SharePoint list/library, and you want to enable a custom action on the items. In the "old days" one would create a Feature that deploys a CustomAction. But nowadays we intend to / or even must (Office 365 /SharePoint Online) avoid server-side (or sandbox) deployments. Clientside-development is the new mantra. SharePoint as webapplication is strongly javascript-based, from 2007 and beyond. Also the handling of the Edit-Control-Block menu is via SharePoint javascript code. This makes it possible to "break" into that execution, and inject some own handling. Via F12 DeveloperTools I identified where to 'break into'/extend:
var EnrichListView = window.EnrichListView || {}; EnrichListView.UI = function () { function MyCustomItemAction(itemId) { // do something with the item identified by 'itemId' } function OverloadMenuHtc_show() { $.prototype.base_MenuHtc_show = MenuHtc_show; MenuHtc_show = function(oMaster, oParent, fForceRefresh, fFlipTop, yOffset) { if ($(oMaster).find("SPAN[id='ID_EditItem']").length == 1) { var itemId = oParent.childNodes[0].id; var spanInsert = $("<SPAN text='Custom Action on item' onMenuClick='EnrichListView.UI.MyCustomItemAction(" + itemId + ")' type='option' iconAltText sequence='200' CUICommand='CustomActionItems' ></SPAN>"); $(oMaster).append(spanInsert); } $.prototype.base_MenuHtc_show( oMaster, oParent, fForceRefresh, fFlipTop, yOffset); return false; }; } var ModuleInit = (function() { ExecuteOrDelayUntilScriptLoaded(OverloadMenuHtc_show, "core.js"); })(); // Public interface return { MyCustomItemAction: MyCustomItemAction } }();
Result:
This client-side approach is on itself future-proof, and also works online. The only caveat is that online Microsoft may change the javascript handling/code on which this code breaks into, without you knowing. The javascript code can also change due an update to SharePoint on-premise installation, but then you're aware of a change in the SharePoint installation and can prepare for the change in advance.

Friday, July 22, 2016

(scroll)height of children influences scrollheight of page

This is a follow-up on earlier post ‘Freeze pane on SharePoint list’
Business users very much appreciate the inserted functionality to freeze the listview header in visible top of screen, while one scrolls through the content. However, they reported a problem in case of smaller display size: it was not possible to scroll completely to the bottom, and also the page navigation controls cannot be reached.
The cause is that in my initial code I decided to unconditional hide the page scrollbar. I decided for this as otherwise the page scrollbar would remain in state to scroll through a large list, despite my changes to reduce the height of that list and give it a scrollbar for itself.
The simple way out to resolve the reported issue would be to just not hide the page scrollbar. But from User Experience point of view I dislike that, the result with 2 scrollbars and the page scrollbar over full initial height, is confusing for end users. I instead opted for only displaying the scrollbar when needed (screen smaller as minimal height for listview), and in such case reduce the scrollheight of page to only scroll to the bottom of the height-reduced listview. Thus not for the full initial height.
At first (html) code attempts this was not that simple to achieve, whatever reduction I made still the page scrollheight remained at the initial large value. Via debugger I identified the cause. The scrollheight of the listview remained at the large value, despite that I reduced the visible height. And the scrollheight of page as parent is directly influenced by the sum of scrollheights of the child elements in page. The scrollheight value is readonly, thus not possible to also reduce this to match the reduced height of the listview Div element.
Post ‘Making elements not affect page height (thus scrolling)’ put me on track to break out of this: It appears that scrollheight of relative positioned children has impact; but not that of absolute positioned children. So what I needed to change in my initial FreezePane code was to break the relative parent-child positioning relation for the listview with respect to page. This is accomplished by following changes:
// Set visible height, with minimum of 300 var visibleTableHeight = Math.max(availableHeight, 300); // $table.wrap("<DIV class='FreezedLV' style='OVERFLOW: auto; HEIGHT: 500px;'></DIV>"); $table.wrap("<DIV class='FreezedLV' style='position:absolute; left:0; top: 0; width:100%; OVERFLOW: auto; HEIGHT: " + visibleTableHeight + "px;'></DIV>"); // $(".FreezedLV").wrap("<DIV class='FreezedLVContainer'></DIV>"); $(".FreezedLV").wrap("<DIV class='FreezedLVContainer' style='position:relative; margin:0px; height:" + (visibleTableHeight + freezedTRHeight) + "px;'></DIV>");
The end-result: now truly happy users, some of which forced to view the page on small(er) laptop screensizes.
The complete 'FreezePane' method:
function FreezePane() { var $table = $(".ms-listviewtable").first().data("summary", "list name"); // Determine the available height for table to render in visible screen. var origWorkspaceHeight = $("#s4-workspace").height(); var origTableHeight = $table.height(); var spaceInWorkspaceWithoutTable = origWorkspaceHeight - origTableHeight; var windowHeight = window.innerHeight; if (windowHeight == undefined) { windowHeight = document.documentElement.clientHeight; } var topPos = $("#s4-workspace").offset().top; var freezedTRHeight = 25; var availableHeight = windowHeight - topPos - spaceInWorkspaceWithoutTable - freezedTRHeight; // Set visible height, with minimum of 300 var visibleTableHeight = Math.max(availableHeight, 300); // WRAP TABLE IN SCROLL PANE $("#s4-workspace").css( { 'overflow-y': 'auto' } ); $table.wrap("<DIV class='FreezedLV' style='position:absolute; left:0; top: 20; width:100%; OVERFLOW: auto; HEIGHT: " + visibleTableHeight + "px;'></DIV>"); // FROZEN HEADER ROW $(".FreezedLV").wrap("<DIV class='FreezedLVContainer' style='position:relative; margin:0px; height:" + (visibleTableHeight + freezedTRHeight) + "px;'></DIV>"); $("<table id='FreezedTR' class='ms-listviewtable' cellPadding='1' cellSpacing='0'></table>").insertBefore(".FreezedLV"); $("#FreezedTR").width($table.width() + "px"); var $origHeader = $("TR.ms-viewheadertr:first", $table); var $firstRowTable = $("TR.ms-itmhover:first", $table); var $freezeHeader = $origHeader.clone(); // Propagate computed width of columns of origheader to freezeheader $origHeader.children("th").each(function() { var width = $(this).width(); var ownerIndex = $(this).index(); $($freezeHeader.children("th")[ownerIndex]).width(width + "px"); }); $("#FreezedTR").append($freezeHeader); $("#FreezedTR").append($firstRowTable.clone()); $("#FreezedTR").wrap("<DIV style='OVERFLOW: hidden; HEIGHT: " + freezedTRHeight + "px;'></DIV>"); // Visualize "hide" the orig header, make sure it still is rendered as otherwise the alignment of 'body' rows is altered $origHeader.children("th").each(function() { $(this).css( { 'height' : '0px' , 'max-height' : '0px', 'min-height' : '0px' , 'padding-top' : '0px', 'padding-bottom' : '0px' } ); $(this).find("div").css( { 'height' : '0px' , 'max-height' : '0px', 'min-height' : '0px', 'margin-top' : '0px', 'margin-bottom' : '0px' } ); $(this).find("input").css( { 'height' : '0px' , 'max-height' : '0px', 'min-height' : '0px' } ); }); $origHeader.css( { 'max-height' : '1px', 'visibility' : 'hidden' } ); // Delegate eventhandlers from the copied+freezed header to the actual header of the listview. var $inputFH = $freezeHeader.find("input[title='Select or deselect all items']"); $inputFH[0].onclick = null; $inputFH[0].onfocus = null; $table.onmouseover = null; $inputFH.click(function() { $(this).closest(".FreezedLVContainer").find(".FreezedLV .ms-viewheadertr").find("input[title='Select or deselect all items']").trigger('click'); }); $inputFH.focus(function() { $(this).closest(".FreezedLVContainer").find(".FreezedLV .ms-viewheadertr").find("input[title='Select or deselect all items']").trigger('focus'); }); $("#FreezedTR").mouseover(function() { $(this).closest(".FreezedLVContainer").find(".FreezedLV .ms-listviewtable").trigger('mouseover'); }); }

Thursday, April 21, 2016

Freeze pane on SharePoint list

In case of a larger SharePoint list, end-users may appreciate that the header row remains in the visible area while they scroll through the list. Out-of-the-box the SharePoint XsltListView webpart does not provide this functionality. However, it is possible to augment the standard behavior by modifying the rendered HTML through javascript. There are multiple examples published, but for some reason these did not immediate work for me. Did get me inspired though, and I coded a jQuery-snippet that when inserted in a page containing a XsltListView webpart delivers the 'freeze pane':
<script src="https://code.jquery.com/jquery-1.11.2.min.js" type="text/javascript"></script> <script type="text/javascript"> $(document).ready(function () { var $table = $(".ms-listviewtable").first().data("summary", "<listview name>"); // WRAP TABLE IN SCROLL PANE $("#s4-workspace").css( { overflow: 'hidden' } ); $table.wrap("<DIV class='FreezedLV' style='OVERFLOW: auto; HEIGHT: 550px;'></DIV>"); // FROZEN HEADER ROW $(".FreezedLV").wrap("<DIV class='FreezedLVContainer'></DIV>"); $("<table id='FreezedTR' class='ms-listviewtable' cellPadding='1' cellSpacing='0'></table>"). insertBefore(".FreezedLV"); $("#FreezedTR").width($table.width() + "px"); var $origHeader = $("TR.ms-viewheadertr:first", $table); var $firstRowTable = $("TR.ms-itmhover:first", $table); var $freezeHeader = $origHeader.clone(); // Propagate the computed width of columns of origheader to freezeheader $origHeader.children("th").each(function() { var width = $(this).width(); var ownerIndex = $(this).index(); $($freezeHeader.children("th")[ownerIndex]).width(width + "px"); }); // Propagate the implicit width of row columns as explicit widths in first row to // avoid layout-changes when hiding the original header $firstRowTable.children("td").each(function() { var width = $(this).width(); var ownerIndex = $(this).index(); $($firstRowTable.children("td")[ownerIndex]).width(width + "px"); }); $("#FreezedTR").append($freezeHeader); $origHeader.hide(); }); </script>

Thursday, February 18, 2016

Directly open attachment from XsltListView UI

A business department composed a knowledge system as SharePoint-enabled Business App. The knowledge items are structured information about defects, with aspects as system, impact, fix available, owner. At a conceptual level, the correct SharePoint information architecture to administrate this is as list items. Some, not all, of the knowledge items can have associated documents. Important to realize is that the document(s) accompanying a knowledge item is/are secondary level information, it is not the main entity. So it is a valid decision to not set up as SharePoint document library.
However, on document level a library has convenient user experience. You click on the title of a document item, and it opens up: in browser or in native client, dependent on library settings and type of the document item. Our business requested the same convenient user experience for those knowledge items that have an associated document as attachment. Direct open from list, avoid the cumbersome usage path to first open the item and from the item dialog open the attachment in case present for the item.
A javascript approach can be applied to modify/extend the native XsltListViewWebPart behavior to deliver on the above. It relies on a combination of dynamic javascript overloading, SharePoint REST service, and SharePoint OWA viewers. Below the code snippet.
var EnrichKnownIssuesView = window.EnrichKnownIssuesView || {}; EnrichKnownIssuesView.UI = function () { function OverloadEditLink() { EditLink2 = function(target,nr) { var href = $(target).attr('href'); var idStart = href.substr(href.indexOf("&ID=") + 4); var id = idStart.substr(0, idStart.indexOf("&")); var siteUrl = window.location.href.substr(0, window.location.href.indexOf("/default.aspx")); if (siteUrl.length > 0) { var getAttachmentsUrl = siteUrl + "/_vti_bin/ListData.svc/KnownIssuesList(" + id + ") ?$expand=Attachments&$select=Id,Attachments"; $.getJSON(getAttachmentsUrl, function (data) { if ( data.d.Attachments != null && data.d.Attachments.results != null && data.d.Attachments.results.length > 0 ) { for (var i = 0; i < data.d.Attachments.results.length; i++) { var attachmentsUrl = siteUrl + "/Lists/Known Issues List/Attachments/" + data.d.Id + "/" + data.d.Attachments.results[i].Name; /* For some files, open in Office Web Applications results in error "Service not available". To prevent that the user then will not be able to view the document, configure the web-address of native document as source --> it will then open in native client. */ var fileName = data.d.Attachments.results[i].Name.toLowerCase(); if (fileName.endsWith(".docx") || fileName.endsWith(".doc")) { attachmentsUrl = siteUrl + "/_layouts/WordViewer.aspx?id=" + encodeURI(attachmentsUrl) + "&Source=" + encodeURI(attachmentsUrl) + "&DefaultItemOpen=1"; } else if (fileName.endsWith(".xlsx") || fileName.endsWith(".xls")) { attachmentsUrl = siteUrl + "/_layouts/xlviewer.aspx?id=" + encodeURI(attachmentsUrl) + "&Source=" + encodeURI(attachmentsUrl) + "&DefaultItemOpen=1"; } else if (fileName.endsWith(".pptx") || fileName.endsWith(".ppt")) { attachmentsUrl = siteUrl + "/_layouts/PowerPoint.aspx?PowerPointView=ReadingView&PresentationId=" + encodeURI(attachmentsUrl) + "&Source=" + encodeURI(attachmentsUrl) + "&DefaultItemOpen=1"; } window.open(attachmentsUrl, '_blank'); } } }); } }; } var ModuleInit = (function() { ExecuteOrDelayUntilScriptLoaded(OverloadEditLink, "inplview.js"); })(); }();
Special attention in the above for the usage of the 'Source' querystring parameter. During testing of the overloaded EditLink2 function, I occassional encountered an error in which OWA reports an error:
Opening the document in native Office client does succeed. I could have resorted to just always let the document open in native Office, but honestly I dislike that. With the OWA usage model, there is no need to download the document to the client system (consider Information Rights Management and Data Loss Protection), nor a need to have MS Office installed on the specific client device. To give that completely up, just for the rare occurence of a document on which OWA opening fails is a pitty. Instead I apply a 2-step approach: I let the Business App first try to open the Office document in OWA, which in most of the cases succeeds. In the rare case this fails, after the user clicks 'away' the above error message, the browser next 'returns' to the url specified in the 'Source' querystring parameter thus opening the document in the native Office client. Via this 2-steps approach, it is assured that the user can view the document: preferable in the browser, or in the rare problem situation in the native Office client. Simple and pragmatic.

Monday, February 15, 2016

Overload Library behavior to open Pdf in browser

A business request is to deliver via SharePoint an "e-books" functionality in which the site visitors can read documents, without need to download them. For Microsoft Office file formats, SharePoint supports this via Office Web Apps (OWA). But the majority of publication documents is in Acrobat PDF format. Even when a document library is configured to open documents in browser, SharePoint defaults to open PDF documents in the native PDF client. However, with the power of JavaScript at hand this default behavior can simple be overloaded:
var EnrichEBooksView = window.EnrichEBooksView || {}; EnrichEBooksView.UI = function () { function OverloadDispEx() { var baseDispEx = DispEx; DispEx = function(o,n,e,a,d,i,g,m,k,b,h,j,f,c,l) { if (m == 'PdfFile.OpenDocuments') { m = 'SharePoint.OpenDocuments'; } return baseDispEx(o,n,e,a,d,i,g,m,k,b,h,j,f,c,l); }; } var ModuleInit = (function() { ExecuteOrDelayUntilScriptLoaded(OverloadDispEx, "core.js"); })(); }();

Sunday, July 12, 2015

Tabbed view on document library

The ‘group by’ native functionality of XsltListViewWebPart is convenient to present a classified view on the contents of a SharePoint List / Library. Requirement of one of our departments is to go beyond that, and provide a tabbed view on the document library: a tab per month per year.
To achieve this, one basically has the following options:
  1. Build a custom webpart. However, this is so old-school SharePoint platform utilization; and in our company by default disallowed.
  2. Build a custom HTML / javascript UI (App), and connect via SharePoint webservices. Although this setup nicely fits in ‘modern SharePoint App-development’, for this specific scenario the drawback is that you then also need to develop yourself all of the Office integration that SharePoint standard delivers for a rendered document library (via ECB menu).
  3. Reuse XsltListViewWebPart, but specify an own Xslt-styling. This approach suffers from the same approach as the ‘modern App’ alternative: you’re then required to include in the custom Xstl all the code to render Office-integration friendly.
  4. Reuse XsltListViewWebPart, and dynamically modify the standard grouped-by layout into a tabbed view. Beyond reuse of the native Office-integration, this approach also reuses the lazy loading per group that is native in the XsltListViewWebPart group-by handling. Especially with a larger document library, this makes it much more performant as to retrieve the entire contents at once.

Client-side transfrom group-by layout into tabbed view

The transformation of the standard group-by layout into a tabbed view can be achieved as full client-side code only. To achieve the effect, I inspected the standard delivered html; and next coded the transformation logic in jQuery.

Particulars

  • The native 'group-by' functionality renders the header(s) of the groups. In a tabbed-view layout, the selected tab however already visualizes which group selected; and the group-headers are undesirable in the rendering.
  • The native 'group-by' functionality opens new group in addition to the one(s) already open. For a tab-view experience, the views must be exclusive, act as toggles. Select one tab, automatically closes the tab selected before.
  • The native 'group-by' functionality also includes a 'remember' function: by default a grouped-by layout opens with the group(s) opened as when the visitor was last on the page. For a consistent user-experience, it is then required to pre-select the associated tab-button.

The 'App' code

<style type="text/css"> .et-tab { <ommitted…> } .et-tab-active { <ommitted…> } .et-tab-inactive { <ommitted…> } .et-separator { height: 5px; background-color: rgb(134, 206, 244); } </style> <script> var TabbedListView = window.TabbedListView || {}; TabbedListView.UI = function () { function MonthToInt(month) { <ommitted…> } function getCookieValue(cookieName) { if (document.cookie.indexOf(cookieName) != -1) { var cookies = document.cookie.split("; "); for (var cookieSeq in cookies) { var cookieSpec = cookies[cookieSeq]; if (cookieSpec.indexOf(cookieName) != -1 && cookieSpec.indexOf("=") != -1 ) { return unescape(cookieSpec.split("=")[1]); } } } return undefined; } function TabbedView() { var tabrow = $("<div class='et-tabrow'></div>"); $(".ms-listviewtable") .before($(tabrow)) .before("<div class='et-separator'></div>"); $(".ms-listviewtable").children().each(function(i) { // Grouping-row: level 0 or level 1 if ($(this).attr("groupString") !== undefined) { // Month - lowest group level. if ($(this).children("[id='group1']").length > 0) { var action = $("<a></a>"); // Set the buttonlabel := '<month> <year>' by extracting // the values from the original headings. var monthValue = $(this).find("a").parent().clone() .children().remove().end().text().split(" : ")[1]; var parentId = $(this).attr('id') .substring(0, $(this).attr('id').length - 2); var group0 = $(this).parent().children("[id='" + parentId + "']"); var yearValue = $(group0).find("a").parent().clone() .children().remove().end().text().split(" : ")[1]; $(action).text(monthValue + " " + yearValue); $(action).click(function() { var parentId = $(this).parent().attr('id'); var parentTBodyId = "titl" + parentId.substring(0, parentId.length -2; var actualAA = $(".ms-listviewtable") .find("tbody[id='" + parentTBodyId + "']").find("a"); if ($(actualAA).find('img').attr('src') .endsWith("plus.gif") ) { $(actualAA).trigger('click'); } var actualA = $(".ms-listviewtable") .find("tbody[id='titl" + parentId + "']").find("a"); $(actualA).trigger('click'); if ($(this).parent().hasClass("et-tab-inactive")) { $(".ms-listviewtable").children().each(function(i) { if ($(this).attr("groupString") !== undefined) { $(this).hide(); } }); $(".et-tabrow").children().each(function(i) { if ($(this).hasClass("et-tab-active")) { $(this).find("a").click(); } }); $(this).parent().removeClass("et-tab-inactive"); $(this).parent().addClass("et-tab-active"); } else { $(this).parent().removeClass("et-tab-active"); $(this).parent().addClass("et-tab-inactive"); } }); // Add 'tab-button' to tab-row; in chronological sorted order. var button = $("<span class='et-tab'></span>"); $(button).attr('id', $(this).attr('id').substring(4, $(this).attr('id').length)); $(button).append($(action)); var totalMonths = parseInt(yearValue) * 12 + MonthToInt(monthValue); $(button).data('TotalMonths',totalMonths); var added = false; $(".et-tabrow").children().each(function(i) { if (!added && parseInt($(this).data("TotalMonths")) > totalMonths) { $(this).before($(button)); added = true; } }); if (!added) $(tabrow).append($(button)); $(button).addClass("et-tab-inactive"); } $(this).hide(); } }); ExecuteOrDelayUntilScriptLoaded(function() { var cookieValue = getCookieValue("WSS_ExpGroup_"); var group1Opened = false; if (cookieValue !== undefined) { var expGroupParts = unescape(cookieValue).split(";#"); for (var i = 1; i < expGroupParts.length - 2; i++) { if (expGroupParts[i+1] !== "&") { group1Opened = true; break; } else { i++; } } } if (group1Opened) { // XsltListViewWebPart standard behaviour includes a 'remember' // functionality: open the group(s) that was/were open before // refreshing the page with the grouped-view. Overload that behaviour // to make sure the 'tab-row' state is consistent with that. $.prototype.base_ExpColGroupScripts = ExpColGroupScripts; ExpColGroupScripts = function(c) { var result = $.prototype.base_ExpColGroupScripts(c); $(".ms-listviewtable").find("tbody[isLoaded]").each(function(i) { if ($(this).find("td").text() === 'Loading....') { var bodyId = $(this).attr('id') .substring(4, $(this).attr('id').length-1); var tabButton = $(".et-tabrow") .children("[id='" + bodyId + "']"); if ($(tabButton).hasClass("et-tab-inactive")) { $(tabButton).removeClass("et-tab-inactive"); $(tabButton).addClass("et-tab-active"); } } }); // Reset function ExpColGroupScripts = $.prototype.base_ExpColGroupScripts; return $(result); }; } else { $(".et-tabrow span:first-child").find("a").trigger('click'); } }, "inplview.js"); $(".ms-listviewtable").show(); } var ModuleInit = (function() { $(".ms-listviewtable").hide(); _spBodyOnLoadFunctionNames.push("TabbedListView.UI.TabbedView"); })(); // Public interface return { TabbedView: TabbedView } }(); </script>

Update: support for multiple XsltListViewWebParts on page

The above 'App' code works fine in case of a single XsltListViewWebPart on page. However, in our company we also have document dashboards that give entrance to 'archived' and 'active' documents. The above code requires some update to be usable for 1 or more XsltListViewWebPart instances on a single page.
<style type="text/css"> <ommitted…> </style> <script> var TabbedListView = window.TabbedListView || {}; TabbedListView.UI = function () { function MonthToInt(month) { <ommitted…> } function getCookieValue(cookieName) { var cookieNameLC = cookieName.toLowerCase(); if (document.cookie.toLowerCase().indexOf(cookieNameLC) != -1) { var cookies = document.cookie.split("; "); for (var cookieSeq in cookies) { var cookieSpec = cookies[cookieSeq]; if (cookieSpec.toLowerCase().indexOf( cookieNameLC) != -1 && cookieSpec.indexOf("=") != -1) { return unescape(cookieSpec.split("=")[1]); } } } return undefined; } var triggerCtxIsInit = false; function initTabSelection(webpartId) { var lstVw = $('div[WebPartID^="' + webpartId + '"]'); var cookieValue = getCookieValue("WSS_ExpGroup_{" + webpartId + "}"); var group1Opened = false; if (cookieValue !== undefined) { var expGroupParts = unescape(cookieValue).split(";#"); for (var i = 1; i < expGroupParts.length - 2; i++) { if (expGroupParts[i+1] !== "&") { group1Opened = true; break; } else { i++; } } } if (group1Opened) { // XsltListViewWebPart standard behaviour includes a 'remember' // functionality: open the group(s) that was/were open before // refreshing the page with the grouped-view. Overload that // behaviour to make sure the 'tab-row' state is consistent with that. if ($.prototype.base_ExpColGroupScripts === undefined) { $.prototype.base_ExpColGroupScripts = ExpColGroupScripts; ExpColGroupScripts = function(c) { var result = $.prototype.base_ExpColGroupScripts(c); $(".ms-listviewtable").find("tbody[isLoaded]").each(function(i) { if ($(this).find("td").text() === 'Loading....') { var bodyId = $(this).attr('id') .substring(4, $(this).attr('id').length-1); var tabButton = $(".et-tabrow").children("[id='" + bodyId + "']"); if ($(tabButton).hasClass("et-tab-inactive")) { $(tabButton).removeClass("et-tab-inactive"); $(tabButton).addClass("et-tab-active"); } } }); return $(result); }; } } else { triggerCtxIsInit = true; $(lstVw).parent().find(".et-tabrow span:first-child") .find("a").trigger('click'); triggerCtxIsInit = false; } } function TabbedView() { $(".ms-listviewtable").each(function(i) { ExecTabbedView($(this)); }); } function ExecTabbedView(lstVw) { var tabrow = $("<div class='et-tabrow'></div>"); $(lstVw).before($(tabrow)).before("<div class='et-separator'></div>"); $(lstVw).children().each(function(i) { // Grouping-row: level 0 or level 1 if ($(this).attr("groupString") !== undefined) { // Month - lowest group level. if ($(this).children("[id='group1']").length > 0) { var action = $("<a></a>"); // Set the buttonlabel := '<month> <year>' by extracting // the values from the original headings. var monthValue = $(this).find("a").parent().clone().children() .remove().end().text().split(" : ")[1]; var parentId = $(this).attr('id') .substring(0, $(this).attr('id').length - 2); var group0 = $(this).parent().children("[id='" + parentId + "']"); var yearValue = $(group0).find("a").parent().clone().children() .remove().end().text().split(" : ")[1]; $(action).text(monthValue + " " + yearValue); // Add clickhandler to: // - check the 'parent-group-header in the table whether already // opened; if not trigger it to open. This is required to reuse // the standard XsltListViewWebPart behaviour wrt remember // state upon refresh. // - invoke the 'original' one of the group-header A in the // table; to trigger the default behaviour // - if 'selected': // - hide the headings that are visualized by the default // clickhandler // - deselect the 'tab' that is current active // - visualize the 'tab' to display as active // - if 'deselected' // - visualize the 'tab' to display as inactive $(action).click(function() { // On first user-initiated click; reset the overload of // ExpColGroupScripts as only applicable on initialization. if (!triggerCtxIsInit && $.prototype.base_ExpColGroupScripts !== undefined ) { ExpColGroupScripts = $.prototype.base_ExpColGroupScripts; $.prototype.base_ExpColGroupScripts = undefined; } var parentId = $(this).parent().attr('id'); var tabrow = $(this).parents('div[class^="et-tabrow"]'); var lstVw = $(tabrow).parent() .find('table[class^="ms-listviewtable"]'); var actualAA = $(lstVw).find("tbody[id='titl" + parentId.substring(0, parentId.length -2) + "']") .find("a"); if ($(actualAA).find('img').attr('src') .endsWith("plus.gif") ) { $(actualAA).trigger('click'); } var actualA = $(lstVw).find("tbody[id='titl" + parentId + "']").find("a"); $(actualA).trigger('click'); if ($(this).parent().hasClass("et-tab-inactive")) { $(lstVw).children().each(function(i) { if ($(this).attr("groupString") !== undefined) { $(this).hide(); } }); $(tabrow).children().each(function(i) { if ($(this).hasClass("et-tab-active")) { $(this).find("a").click(); } }); $(this).parent().removeClass("et-tab-inactive"); $(this).parent().addClass("et-tab-active"); } else { $(this).parent().removeClass("et-tab-active"); $(this).parent().addClass("et-tab-inactive"); } }); // Add 'tab-button' to tab-row; in chronological sorted order. var button = $("<span class='et-tab'></span>"); $(button).attr('id', $(this).attr('id') .substring(4, $(this).attr('id').length)); $(button).append($(action)); var totalMonths = parseInt(yearValue) * 12 + MonthToInt(monthValue); $(button).data('TotalMonths',totalMonths); var added = false; $(tabrow).children().each(function(i) { if (!added && parseInt($(this).data("TotalMonths")) > totalMonths) { $(this).before($(button)); added = true; } }); if (!added) $(tabrow).append($(button)); $(button).addClass("et-tab-inactive"); } $(this).hide(); } }); var webpartId = $(lstVw).parents('div[WebPartID^!=""]').attr('WebPartID'); ExecuteOrDelayUntilScriptLoaded( function () { initTabSelection(webpartId) }, "inplview.js"); $(lstVw).show(); } var ModuleInit = (function() { $(".ms-listviewtable").each(function(i) { $(this).hide(); }); _spBodyOnLoadFunctionNames.push("TabbedListView.UI.TabbedView"); })(); // Public interface return { TabbedView: TabbedView } }(); </script>