Simple WebDrawer ADFS

Background

Setting up a connection between WebDrawer and one of our lab ADFS servers is something I only do occasionally.   Now I am documenting it for the next time I do it.  This is a very simple setup so I do not promise that it will work the same in your environment.

Watch me

The config

Here I detail the various changes made to the WebDrawer web.config in the previous video.

configSections

We identify the two sections we will be configuring later in web.config in the configSections element (usually at the top of web.config)

<configSections>
  ...
<section name="system.identityModel" type="System.IdentityModel.Configuration.SystemIdentityModelSection, System.IdentityModel, Version=4.0.0.0, Culture=neutral, PublicKeyToken=B77A5C561934E089" />
<section name="system.identityModel.services" type="System.IdentityModel.Services.Configuration.SystemIdentityModelServicesSection, System.IdentityModel.Services, Version=4.0.0.0, Culture=neutral, PublicKeyToken=B77A5C561934E089" />
  
</configSections>

appSettings

In appSettings we provide the location of the ADFS metadata to allow WebDrawer to interrogate this later.

<appSettings file="user.config">
...
<add key="ida:FederationMetadataLocation" value="https://adfs1.testteam.local/FederationMetadata/2007-06/FederationMetadata.xml" />
  
</appSettings>

authorization

Here we deny access to anonymous users (thus requiring authentication and disable standard windows authentication.

<authorization>
   <deny users="?" />
</authorization>
<authentication mode="None" />

system.IdentityModel

I usually add the system.IdentityModel section just before system.webServer, although I believe it may go anywhere.

<system.identityModel>
<identityConfiguration>
  <audienceUris>
    <add value="[Your WebDrawer URL]" />
  </audienceUris>
  <securityTokenHandlers>
    <add type="System.IdentityModel.Services.Tokens.MachineKeySessionSecurityTokenHandler, System.IdentityModel.Services, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" />
    <remove type="System.IdentityModel.Tokens.SessionSecurityTokenHandler, System.IdentityModel, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" />
  </securityTokenHandlers>
  <certificateValidation certificateValidationMode="None" /> 
  <issuerNameRegistry type="System.IdentityModel.Tokens.ValidatingIssuerNameRegistry, System.IdentityModel.Tokens.ValidatingIssuerNameRegistry">
    <authority name="http://[Your ADFS Server]/adfs/services/trust">
      <keys>
      </keys>
      <validIssuers>
        <add name="http://[Your ADFS Server]/adfs/services/trust" />
      </validIssuers>
    </authority>
  </issuerNameRegistry>
</identityConfiguration>
</system.identityModel>

modules

The modules go inside system.webServer, I usually place them just after the handlers.

<modules>
  <add name="WSFederationAuthenticationModule" type="System.IdentityModel.Services.WSFederationAuthenticationModule, System.IdentityModel.Services, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" preCondition="managedHandler" />
  <add name="SessionAuthenticationModule" type="System.IdentityModel.Services.SessionAuthenticationModule, System.IdentityModel.Services, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" preCondition="managedHandler" />
</modules>

WIFHandler

I add this location to avoid a validation error triggered by the re-direct back from the ADFS login page.

<location path="WIFHandler">
  <system.web>
    <httpRuntime requestValidationMode="2.0" />
  </system.web>
</location>

system.identityModel.services

<system.identityModel.services>
  <federationConfiguration>
    <cookieHandler requireSsl="true" />
    <wsFederation passiveRedirectEnabled="true" issuer="https://[Your ADFS Server]/adfs/ls" realm="[Your Realm]" reply="https://[Your WebDrawer URL/WIFHandler" requireHttps="true" />
  </federationConfiguration>
</system.identityModel.services>

WebDrawer and Cache-Control

Background

I occasionally get asked about setting the Cache-Control headers on WebDrawer responses.  By default WebDrawer sets the Cache-Control header to 'private', which means that the only place the content should be cached is in the end user's browser, not on any intermediate servers.

No-Cache

It seems that there are various certification tools that require cache-control headers other than 'private' be sent, for example No-Cache or No-Store.  While it is not possible to stop WebDrawer from sending private we can send these other directives in addition.

How To

Adding the customHeaders element within system.WebServer/httpProtocol (in web.config) will allow you to add options to cache-control.  The example below will result in the Cache-Control response header being set to 'private,No-Cache'.  Given that 'No-Cache' is more restrictive than 'private' this is equivalent to setting the Cache-Control header to 'No-Cache'.

<system.webServer>
      <httpProtocol>
      <customHeaders>
        <clear />
        <add name="Cache-Control" value="No-Cache"/>
      </customHeaders>
    </httpProtocol>
  ...
</system.webServer>

What about things I do want to cache?

The setting above will set the Cache-Control header on all responses, including CSS, JavaScript and images, you may not want to do this.  We can switch these off by adding something similar to the above in the location element for the paths in which these static files are found.  Each of the paths 'scripts', 'css' and 'images' already has a location element in the WebDrawer web.config, we can modify each of these to look like the 'scripts' one below, this will remove any cache-control header set above.

<location path="scripts" inheritInChildApplications="false">
  <system.web>
    <authorization>        
      <allow users="*" />
    </authorization>
  </system.web>
  <system.webServer>
    <httpProtocol>
      <customHeaders>
        <clear />
        <remove name="Cache-Control" />
      </customHeaders>
    </httpProtocol>
  </system.webServer>
</location>

Does this matter?

The fact that non-static WebDrawer pages (e.g. any search, record details etc) do not contain cache validation headers (such as Last-Modified or ETag) means that it is very unlikely that they will be cached on intermediate servers (as discussed here), however you may still wish to explicitly forbid this.   In addition, even though a browser is most likely to reload a dynamic page it is still likely to cache it on the user's local machine (in Google Chrome use chrome://cache to view cached pages).  Security concerns may mean you wish to prevent pages being cached locally, in which case you would set the header 'No-Store'.

More Information

Here are some posts I have found useful in discussing Cache-Control:

My first HPRM SDK application

Introduction

If you are not a programmer but want to do some simple HPRM SDK programming here is a start.  It is actually quite easy to achieve some fairly powerful outcomes, like anything the curve gets steeper as your ambition grows.

The Video

Please forgive my constant sniffing in this video, I have been battling a cold for a week.

The Code

using (Database database = new Database())
{
    database.Id = "F1";
    database.Connect();

    using (TrimMainObjectSearch search 
           = new TrimMainObjectSearch(database, BaseObjectTypes.Record))
    {
        search.SetSearchString("electronic");
        using (StreamWriter textFile 
           = File.CreateText(@"d:\junk\myexport.csv"))
        {
            foreach (Record record in search)
            {
                textFile.WriteLine(string.Format(
                  "{0},{1},{2}",
                  record.Uri, record.Number, record.Title));
            }
        }
    }
}


Change the logo in WebDrawer

Background

The logo in the banner of WebDrawer is the HP logo and the text says 'HP Records Manager'.  Maybe you want different branding?  Changing this is quite simple.

The Razor

The logo is actually two images which are embedded using IMG elements and shown/hidden using CSS.  This is done to allow us to show a different image when WebDrawer is re-sized down to mobile phone size.  You can see this HTML in Views\Shared\_Layout.cshtml, it looks like this:

<div class="innertube">
    <img id="banner-img-large" src="~/images/top-banner-logo.png" />
    <img id="banner-img-small" src="~/images/top-banner-logo-title-SM.png" />
</div>

The process

I recommend you:

  1. create two new images the same size as the existing images,
  2. place them in your own folder (e.g. images\myimages)
  3. change the SRC elements in _Layout.cshtml.

Upgrading

On WebDrawer upgrade don't forget to take a backup of _layout.cshtml (preferrably all files under the WebDrawer folder) so that you can re-apply any changes you made.

Location objects in Razor

The problem

Many record properties are represented by Location objects, for example the owner location.  This post examines the requirements for displaying them in a razor template.

Location as additional field

Most additional fields are simple values, such as strings or numbers.  The location type additional field is slightly more complex.  To display properties of a location you need the correct settings in hptrim.config as well as the correct razor.

Config

Use the defaultProperties config element to specify the location properties you wish to return always, whether they are requested in a particular route or not.  The example below will ensure that FormattedName and FullFormatted name are available. In fact that specifies that we want FormattedName and FullFormatted for every single location object.

  <defaultProperties>
    <clear />
    <add name="Location" properties="FormattedName,FullFormattedName" />
    ...
  </defaultProperties>

Razor

Location objects when used in additional fields use the reference version of the Location object, LocationRef.  This example demonstrates displaying the FormattedName.

LocationRef policeOfficer = trimObject.GetPropertyOrFieldValue("PoliceOfficer") as LocationRef;
if (policeOfficer != null) {
  <h1>@policeOfficer.FormattedName</h1>
}

Location as a property

Of course a location property will work in a similar fashion to the additional field.  First make sure the properties you require are set in defaultProperties then insert them as show here:

<h1>@trimObject.OwnerLocation.FormattedName</h1>

A pre-requisite

The above all assumes that you have pre-requested the additional field or property via the 'properties' request parameter.  For razor applications this is often done in the route, for example:

<add
  name="MyRoute"
  model="RecordFind"
  template="MyTemplate"
  properties="NameString,RecordOwnerLocation,PoliceOfficer"
/>

Using Postman to experiment with the ServiceAPI

Introduction

The ServiceAPI includes a number of facilities for experimenting with service calls, including extensive samples and the 'swagger' interface.  If you want to test your service requests in a less structured way without having to write code then Postman is a great option.

The video

This video is a brief introduction to using Postman demonstrating how to do a simple Record search.

Simple document download

Background

In a previous post I showed how to patch the ServiceAPI TrimClient to fix a problem with downloading child documents (e.g. record revisions).  A customer (Mark from WA) pointed out a way to download a file to a stream without having to write it to a file.

Examples

The following code downloads a document using a stream.  I then save to a file but it could also be passed on as a stream or byte array to another system.

Stream stream = trimClient.Get<Stream>(string.Format("Record/{0}/File/Document", 1843));
using (FileStream fs = File.Create("d:\\junk\\myfile.xml"))
{
    stream.CopyTo(fs);
}

Below is the code to use to get a record revision as a stream.

Stream stream = trimClient.Get<Stream>(string.Format("Record/{0}/RecordRevision/{1}", 1843, 12));

Side benefits

Two side benefits of this approach are:

  1. not having to write a file a file unnecessarily, and
  2. avoiding the non-thread safe approach in the previous post.

Download a child document using the .Net client

Background

Today I fielded a request for help downloading a Record Revision using the ServiceAPI .Net client.  Thinking this should be simple I prepared the sample code below, only to find it failed.  Similar sample code works when downloading a document.  Turns out we have a bug.

ChildDownload request = new ChildDownload();
request.ChildUri = 12;
request.Id = "1843";
request.TrimType = BaseObjectTypes.Record;
request.TrimChildType = BaseObjectTypes.RecordRevision;

trimClient.GetDocument(request, @"d:\junk");

The problem

Inside the GetDocument method there is a routine to work out the name of the document from the content disposition, unfortunately the child documents have a differently format content disposition to the standard document download and our code to parse the content disposition to extract the file name was failing.  So the document gets downloaded but never saved to disk.

Content-Disposition

The purpose of the content disposition header is to suggest a file name for the file we are downloading.  When fetching a document (e.g. http://MyServer/HPRMServiceAPI/Record/1843/File/Document) the content disposition sent by the ServiceAPI looks like this:

Content-Disposition: attachment; filename="test.XML"

Update: The URL to download a child document in recent versions of the ServiceAPI is different to the one below, it look like this:

http://MyServer/ServiceAPI/record/9000000001/children/recordrevision/9000000001

The ServiceAPI uses a slightly different mechanism for downloading a child document (e.g. http://MyServer/HPRMServiceAPI/Record/1843/RecordRevision/12).  And the content disposition sent looks different, like this: 

Content-Disposition: attachment; filename="0F3P09LF001.PDF"; size=3881; creation-date=Wed 27 May 2015 00:38:08 GMT; modification-date=Thu 28 May 2015 09:45:43 GMT; read-date=Wed 27 May 2015 00:38:08 GMT

The solution

I could write a bespoke file download using the .Net WebClient but I can also patch the TrimClient to add a working child download method.  I chose to do the latter.  Below is a C# extension which adds the method MyGetChildDocument to TrimClient.  If the content disposition parser fails it tries to extract using a regular expression, it that fails it uses a fallback name as passed in the method parameters.

public static class ClientExtensions
{
    public static string MyGetChildDocument(
        this TrimClient trimClient, 
        ChildDownload request, 
        string fileDownloadPath, string fallbackName = "temp.tmp")
    {
        if (string.IsNullOrEmpty(fileDownloadPath))
        {
            fileDownloadPath = Environment.CurrentDirectory;
        }

        string docPath = null;
        trimClient.ServiceClient.LocalHttpWebResponseFilter = (response) =>
        {
            string fileName = null;
            string contentDispHeader
                = response.GetResponseHeader("Content-Disposition");

            if (!string.IsNullOrEmpty(contentDispHeader))
            {

                try
                {
                    ContentDisposition contentDisp
                        = new ContentDisposition(contentDispHeader);
                    fileName = contentDisp.FileName;
                }
                catch
                {
                    // content disposition must not match what the ContentDisposition object expects
                }

                if (fileName == null)
                {
                    Match match
                        = Regex.Match(contentDispHeader, "filename=\"([^)]*)\"");

                    if (match.Success)
                    {
                        fileName = match.Groups[1].Value;
                    }
                }

            }

            if (fileName == null)
            {
                fileName = fallbackName;
            }

            docPath = Path.Combine(fileDownloadPath, fileName);
            using (FileStream fs
                = File.Open(docPath, FileMode.Create, FileAccess.Write))
            {
                response.GetResponseStream().CopyTo(fs);
                fs.Close();
            }
        };

        trimClient.ServiceClient.Get(request);
        return docPath;
    }
}

Implementing

The full extension can be downloaded here, simply include it in your project, change its namespace and then make a call like this:

ChildDownload request = new ChildDownload();
request.ChildUri = 12;
request.Id = "1843";
request.TrimType = BaseObjectTypes.Record;
request.TrimChildType = BaseObjectTypes.RecordRevision;

string filePath = trimClient.MyGetChildDocument(request, @"d:\junk");

Deleting Records in the ServiceAPI from .Net

Background

Unfortunately deleting a Record (or other object type) is slightly different to other ServiceAPI requests.  This is both because most requests assume a response and also because the delete service is not explicitly built into the .Net client.

Two ways to delete

Each HPRM object has a Delete service which can be posted to via a URL following this pattern: /Record/{Id}/Delete.  Given that the .Net client classes do not have a facility to post to a specified URL you would need to write your own code to post to this URL.

The second way to delete is to post the deletion request to one of the built-in service endpoints.  This can be done like this:

trimClient.Post((new DeleteRecord() { Id = 1837.ToString(), DeleteContents = true });

Or this:

trimClient.Post(new DeleteMainObject() { TrimType = BaseObjectTypes.Record, Id = "1838" });

The catch

Not every version of the ServiceAPI enables the built-in service endpoints by default.  They can be switched on by adding PreDefinedRoutes to the serviceFeatures in the hptrim.config file.

<hptrim 
        poolSize="1000"
        serviceFeatures="Json,PreDefinedRoutes,Razor,Html" 
        ...
>

How do I create a record relationship?

Intro

There is a lot going on in HPRM and it is not always apparent where to go in the ServiceAPI to do what you want to do.  In this video I have brief look around as I work out how to add a record relationship.

C# Example

This example uses the c# libraries as documented at this path /help/dotnetclient in your ServiceAPI to add a new child relationship to the ChildRelationships collection.

TrimClient trimClient = new TrimClient("http://myserver/HPRMServiceAPI");
trimClient.Credentials = new NetworkCredential("david", "XXXXX");
trimClient.AlwaysSendBasicAuthHeader = true;

Record record = new Record();
record.Uri = 12;
record.ChildRelationships =
   new List<RecordRelationship>() {
       new RecordRelationship() {RelatedRecord = new RecordRef() { Uri = 13}, 
           RelationType = RecordRelationshipType.IsRelatedTo}
    };

trimClient.Post<RecordsResponse>(record);

foreach (RecordRelationship relationship in record.ChildRelationships)
{
     Console.WriteLine(relationship.RelatedRecord.Uri);
}


SOAP web service ConnectionInfo

Background

There is an operation in the SOAP web service called ConnectionInfo.  In recent versions this has been deprecated there is, however, an alternative.  The purpose of ConnectionInfo is to return information about the currently connected user, this is now discoverable via the ObjectStringSearchSelect.

How to get the current connection

The C# code below demonstrates using the ObjectStringSearchSelect operation to find the current user and fetch their Uri.  Of course the Fetch could also be used to retrieve any other Location property.

TrimRequest request = new TrimRequest();

ObjectStringSearchSelect stringSearch = new ObjectStringSearchSelect();
stringSearch.TrimObjectType = "location";
stringSearch.Search = "me";

request.Items = new Operation[] { stringSearch, new Fetch() };

Engine engine = new Engine();
engine.UseDefaultCredentials = true;

TrimResponse response = engine.Execute(request);

foreach (Result res in response.Items)
{
    switch (res.GetType().Name)
    {
        case "FetchResult":
            FetchResult fetchResult = res as FetchResult;
            if (fetchResult.Objects.Length > 0)
            {
                Console.WriteLine(fetchResult.Objects.First().Uri);
            }
            break;
        case "ErrorResult":
            ErrorResult errorResult = res as ErrorResult;
            Console.WriteLine(errorResult.Message);
            break;
        }
}

Show me the SOAP

Below is the SOAP request to search for the Location 'me'.  Also required is a Fetch operation to return the Location Uri.

<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
    <soap:Body>
        <Execute xmlns="http://www.towersoft.com/schema/webservice/trim2/">
            <req>
                <ObjectStringSearchSelect>
                    <TargetForUpdate>false</TargetForUpdate>
                    <IsForUpdate>false</IsForUpdate>
                    <Limit>0</Limit>
                    <Skip>0</Skip>
                    <TrimObjectType>location</TrimObjectType>
                    <Search>me</Search>
                </ObjectStringSearchSelect>
                <Fetch>
                    <TargetForUpdate>false</TargetForUpdate>
                    <Limit>0</Limit>
                    <Populate>0</Populate>
                    <HideVersion>false</HideVersion>
                </Fetch>
                <HideVersionNumbers>false</HideVersionNumbers>
                <ProvideTimingResults>false</ProvideTimingResults>
                <ForceRealTimeCacheUpdate>false</ForceRealTimeCacheUpdate>
            </req>
        </Execute>
    </soap:Body>
</soap:Envelope>


Simplified paging

Why?

In a previous post I used the ExcludeCount option to improve search performance on large datasets, one cost of this is that it breaks the WebDrawer paging widget.  This post looks at re-factoring that widget.

Lets do it

The code

This is the code that I use to replace the standard paging widget in the above video. Tp note in this code is that all the logic is based around the starting position of the search (found in the 'start' query parameter) and the HasMoreItems (found in the response from the search).  If HasMoreItems == true then the next button is enabled, if start > 1 then the previous button is enabled.  The layout (e.g. pagination CSS) is all handled by twitter bootstrap.

{
        int start = 0;
        Int32.TryParse(HttpContext.Current.Request.QueryString["start"], out start);

        var queryString = new System.Collections.Specialized.NameValueCollection(HttpContext.Current.Request.QueryString);

        <div class="pagination pagination-centered">
            <ul id="trim-pagination">
                    <li class="@(start < 2 ? "disabled" : null)">
                        @{
                    queryString["start"] = (start > pageSize ? start - pageSize : 0).ToString();
                    <a href="?@queryString.ToFormUrlEncoded()">&laquo;</a>
                        }
                    </li>

                <li class="@(!this.Model.HasMoreItems ? "disabled" : null)">
                    @if (this.Model.HasMoreItems) {
                        start = (start == 0 ? start + 1 : start);
                        queryString["start"] = (start + pageSize).ToString();
                        <a href="?@queryString.ToFormUrlEncoded()">&raquo;</a>
                    }
                    else
                    {
                        <span>&raquo;</span>
                    }
                </li>
            </ul>
        </div>
    }


ExcludeCount and performance

Background

I am doing some load testing on an HPRM dataset containing 16 million records.  Response times from our web clients were not great, depending on which search query I used of course.  I really wanted to improve this.

ExcludeCount

A ServiceAPI parameter not often mentioned is ExcludeCount (otherwise known as resultsOnly).  This has the effect of committing the SearchTitle and TotalResults properties from the response.  Total results in particular can degrade performance on a large dataset as it calculates the total number of records matched by the query.

ServiceAPI Example

http://[myserver]/HPRMServiceAPI/Record?q=uri:2013778,1208882,1380177,1542060,1378119&format=json&Excludecount=true

WebDrawer Example

Setting ExcludeCount as the default in WebDrawer will result in the search results page no longer displaying the search title.  To do this add the 'resultsOnly' attribute in the Record route defaults, as seen below.  Ensure you set this on the route named Record with the model named Records.

<add 
     name="Record" 
     model="Records" 
     template="WDRecordList"
     properties="RecordRecordType,RecordExtension,RecordTitle,RecordNumber" 
     pageSize="15" 
     resultsOnly="true"/>

The outcome

On my large dataset ExcludeCount more than halved the response time on many requests.

The Cost

In WebDrawer the cost is that the page number links no longer work.  This post examines a solution to the broken paging problem.

ServiceAPI Plugin Example

Why?

The 811 ServiceAPI documentation contains a very simple example of a ServiceAPI plugin.  This example takes it one step forward and demonstrates hows to participate in the ServiceAPI pipeline to get a database connection for the current user.

Your Service

The service below inherits from the class TrimServiceBase which provides you with the property called 'Database'.  This is the best way to get a database connection for the current user as it uses the same mechanism to acquire (and release) a connection as used by every other service in the ServiceAPI.

namespace ServiceAPIPlugin
{

    [Route("/Simple", "GET")]
    public class Simple
    {
        public long Uri { get; set; }
    }

    public class SimpleResponse
    {
        public string Name { get; set; }
        public string RecordTitle { get; set; }
    }

    public class SimpleService : TrimServiceBase
    {
        public object Get(Simple request)
        {
            SimpleResponse response = new SimpleResponse();
            if (request.Uri > 0)
            {
                Record record = new Record(this.Database, request.Uri);
                response.RecordTitle = record.Title;
                response.Name = this.Database.CurrentUser.FormattedName;
            }
            return response;
        }
    }
}

Wire it up

To wire this service into the ServiceAPI:

  • create a project using the above code (or download the complete sample below),
  • copy the file ServiceAPIPlugin.dll to your ServiceAPI bin folder, then
  • add the XML below to the root of your hptrim.config.
<pluginAssemblies>
    <add name="ServiceAPIPlugin" />
</pluginAssemblies>

A JSON response

You can get a JSON response from this service simply by calling it with the appropriate URL, for example:

http://localhost/HPRMServiceAPI/simple?uri=9000000000&format=json

A Razor response

Of course you can also get a Razor response (and you can wire your service up in WebDrawer exactly as you do in the ServiceAPI).  The simplest way to get a Razor generated HTML response is to:

  1. create a file called SimpleServiceResponse.cshtml in the ServiceAPI (or WebDrawer) Views folder, and
  2. put some content similar to what you see below in it.
@using HP.HPTRIM.Service
@using ServiceStack;

@inherits TrimViewPage<ServiceAPIPlugin.SimpleResponse>

<h1>My name is: @this.Model.Name</h1>

<h2>The title of record @this.Request.QueryString["uri"] is @this.Model.RecordTitle</h2>

A complete example

This project contains all you need to create this project in HPRM 811.  Some things to look out for:

  • references to standard ServiceAPI and ServiceStack DLLs,
  • a reference to HP.HPTRIM.SDK.dll to allow us to use the .Net SDK,
  • the service follows the ServiceStack convention of request/response/service, and
  • the method Get in the service is for GET requests.  If you need to update HPRM add a Post method and a new request to use as the parameter in that method (analogous to the 'Simple' class.

Warning

The name of one of the referenced DLLs in the project above will likely change in a future release of HPRM and will necessitate a changing of the reference and a re-compile of the project for it to continue to work.

WebDrawer preview page Links / Actions button

The problem

In some versions of WebDrawer you might have this experience:

  • preview a document,
  • select the Links / Actions button, then
  • find yourself back at the main search page.

The cause

As is often the way a fix to one thing led to a breakage somewhere else.  Some documents (such as email items) contain links to attached files.  These links were breaking. To 'fix' this the following code was added to WDRecordPreview.cshtml.

@section customhead {
    @* This is so links in VMBX files do not link to /RecordHTML*@
    <base href="~/Record/" />
}

Unfortunately the 'base' element broke the link in the Links / Actions menu.

Resolution

Later releases of WebDrawer fix this issue in one of two ways:

  1. embed the preview HTML via an iframe so that the 'base' element is no longer required, or
  2. fix the links in the HTML so that the 'base' element is no longer required. 

Fixing the HTML

WDRecordPreview.cshtml contains this line of code:

@record.Html.Replace("src=\"", "src=\"../Record/")

Replace it with this:

@record.Html.Replace("src=\"", "src=\"../Record/").Replace(string.Format("href=\"{0}", record.Uri), string.Format("href=\"../Record/{0}", record.Uri)).AsRaw()

Also remove the customhead section containing the base element.

Customise the WebDrawer HTML preview page

Update!

The solution I provide to display the native PDF in the preview page is flawed.  A better solution is available here.

The problem

What if the HTML preview of a document is not displaying as you think it should?  Depending on the cause this may be out of our hands, however some problems can be avoided even if they cannot be completely resolved.

An Example

This particular post was spurred by one customer who was experiencing strange black boxes overlaying the HTML rendition of some PDFs.  It turns our that this was a problem in the rendered HTML  being exaggerated by the way the HTML was displayed in WebDrawer.

A Fix

In this video I spend the first half improving the viewing experience by displaying the HTML preview within an iframe, at about the 13 minute mark I look at how you might simply embed the native PDF in the preview page rather than rely on the HTML rendition of the PDF. 

Sorry about the audio levels in this, you may have to turn your volume up to full and listen carefully...

 

Scripts

This is the scripts section as seen in the above video.  It sets the iframe:

  • height from the window heigh less the top position of the iframe (plus a 5 pixel buffer), and
  • width to the window width less the iframe margin (10 pixels) plus the menu width (if the left menu is on the left).
@section scripts {
    <script>
        var resizePreview = function () {
            $('#preview-frame').show();
            $('#preview-frame').css("visibility", "visible");

            var height = $(window).height() - ($('#preview-frame').offset().top + 5);
            $('#preview-frame').height(height);


            var width = $(window).width() - 10;

            if (width > 630) {
                width = width - ($('#leftcolumn').width());
            }
            $('#preview-frame').width(width);
        }

        $(function () {
            $(window).on("load resize", function () {
                resizePreview();
            });
        });
    </script>
}

Styles

These styles simply tweak some of the WebDrawer built-in styles to make the iframe fit better into the page.

@section customhead {
    <style>
        h2 {
            white-space: normal;
        }

        #contentcolumn {
            height: auto;
            padding-bottom: 0;
        }
    </style>
}

Body

The body embeds the preview either using an iframe or as the actual source PDF.

if (record.Html.Length == 0)
{
    <div class="preview-pane">
        <div class="alert">
           <strong>@Translations.lang.error</strong> @Translations.lang.no_html
        </div>
    </div>
}
else
{
    if (record.Extension == "PDF")
    {
        <object 
             id="preview-frame"
             data='~/Record/@record.Uri/file/document?inline'
             type='application/pdf'
              width='100%'
              height='100%'
              style="border:none; margin-left:10px; visibility:hidden;">

            <p>
               It appears your Web browser is not configured to display PDF files.
                    No worries, just <a href='~/Record/@record.Uri/file/document?inline'>click here to download the PDF file.</a>
            </p>

        </object>
}
        else
        {
            <iframe 
               id="preview-frame"
               src="~/Record/@record.Uri/file/html"
               style="border:none; margin-left:10px; display:none;">            </iframe>
        }
    }

Record extension

In the code above record.Extension is referred to, depending on your version of WebDrawer Extension may not be available.  To verify this:

  1. open the hptrim.config file, 
  2. find the customPropertySets element,
  3. within that find the RecordPreview, and
  4. it should contain RecordExtension.

This code sample demonstrates the RecordPreview customPropertySet:

<add name="RecordPreview" properties="Html,Title,IsElectronic,RecordExtension,enabledcommandids,RecordIsInPartSeries,RecordRelatedRecs,RecordRecordType,RecordPrimaryContact,RecordIsContainer"/>

The complete solution

Get the complete template here,  don't forget the other piece in the solution is to edit the routeDefault element in hptrim.config.

Fetch a list of record revisions and display them in WebDrawer

Intro

This video goes start to finish fetching a list of record revisions and creating a list in WebDrawer.  Along the way I show where in the ServiceAPI help I found all the information I needed to achieve this.

Sample Code

In this video I call the ServiceAPI with a URL like the one below to fetch a list of record revisions.

    http://localhost/HPRMServiceAPI/Record/REC_15?properties=ChildRevisions

I write this Razor code:

<ul>
    @foreach (RecordRevision revision in this.Model.Results[0].ChildRevisions)
    {
        <li><a href="~/Record/@this.Model.Results[0].Uri/RecordRevision/@revision.Uri">@revision.Description</a></li>
    }
</ul>

Modify a routeDefault to look like this:

    <add 
      name="Record" 
      model="RecordFind" 
      template="MyRecordDetails" 
        properties="NameString,RecordIsElectronic,enabledcommandids,RecordRelatedRecs,RecordIsContainer,RecordIsInPartSeries,RecordRecordType,RecordPrimaryContact,RecordExtension,ChildRevisions" 
        propertySets="Identification,ChildDetails"/>

Add this custom property Set:

<add name="RecordRevisionChildDetails" propertySets="All" properties="RecordRevisionDescription,NameString"/>

Notes

The custom property set I added above has redundant information.  I specify the propertySet 'all' which means that I do not need to specify the individual properties at all.

Also, you might notice a record revision property in the help that has a numeric ID (in fact I comment on this).  This was the result of me working with a partially re-built instance of HPRM.

And lastly, if you see some odd looking things in WebDrawer (such as the 'Create' button) just ignore them.  They are a part of some other sample code I am currently part way through building.

Download a document directly from a list in WebDrawer

Show Me

The default WebDrawer behaviour requires the user to open the Record details page before they can preview or download the electronic document.  This allows for a simple UI design that is easily made responsive to different screen sizes.  If you would prefer to be able to access the document without first opening the details page this post details some template customisations that will get you there.

Source

Here is the source as demonstrated in the video. You may have noted that I overlooked something, when I added the RecordIsElectronic property to the 'properties' in hptrim.config this had the effect of also adding it to my view.  In MySearchResults.cshtml I have added some code to exclude that property.  It looks something lke this...

The exclusion list

string[] columnsToIgnore = new string[] { "RecordExtension", "RecordIsElectronic" };

The if statement

if (!columnsToIgnore.Any(id => id == pfdef.Id))
{
...

Simplification

If the code in MySearchResults.cshtml is a little too complex for your liking, for example we loop through Model.PropertiesAndFields[this.TrimHelper.TrimType] to get the list of properties to display based on the properties specified in hptrim.config.  You might choose to replace this block with something more like the simple table in this example.

Access properties of object properties in a WebDrawer template

Intro

Some things in the Razor templates of the ServiceAPI and WebDrawer are not all that obvious, one is how to display the name of the record author.  In an earlier post I created a custom search form, this video shows a custom search results template and how to display the name (or any other property) of the record author.

A Future Release...

In a future release accessing properties of property objects should become a little more straightforward.

Optimising the performance of the WebDrawer details page

Intro

WebDrawer is designed to show more rather than less of what a user may wish to see.  Being a simple HTML application it does not aim to do anything as sophisticated as on demand loading of data.  The cost of this approach is that a Record details page contains a lot of information and can be quite slow to load.  If your users typically do not need to see all of this information then you can remove it and improve the load time of this page significantly.

Try It Out

This video demonstrates removing the bulk of the properties displayed on a Record details page and improves load of the page from around 4 seconds to just over 1 second.  I hope you enjoy the soothing sounds of children playing in the background on this video.

Make it more maintainable

While the above will improve the performance of the Record details page it will make it more difficult to upgrade WebDrawer.  One way to improve this situation is to not edit the Razor files that ship with WebDrawer but to copy them and use your own.  This way your changes will not be overwritten by an upgrade. In the picture below I have created a folder called 'MyViews' within which I have made a copy of WDRecordDetails.cshtml (renamed MyRecordDetails.cshtml) and a shared folder in which I have a copy of detailsView.cshtml (renamed MyDetailsView.cshtml).  To use the MyRecordDetails View I had to specify it in the 'template' property of the routeDefault element in hptrim.config.

Important

Some important things to remember:

  • the routeDefa
  • .cshtml file names must be unique even across different folders, ServiceAPI/WebDrawer has no way to locate a particular .cshtml file in a specific folder,
  • your 'MyViews' folder (or folders) must be a child of the standard Views folder,
  • the Shared folder is only a convention, shared .cshtml files may be placed in any folder below 'Views',
  • the hptrim.config is overwritten by the WebDrawer upgrade, you must backup and restore it manually to preserve any changes, and
  • as a precaution backup the entire WebDrawer folder prior to any upgrade.

Files

These views and hptrim.config modified in this sample are, these are samples only, they worked on my HPRM 8.11 (Patch 2) instance but I offer no guarantees to anyone else.