Thursday, November 16, 2017

How to add temporary columns to a List View using CSR, in order to display dynamic information not stored in the list.

I have found it useful sometimes to use the CSR framework to display extra information in a List View that isn't necessarily stored on each list item.  This is especially true of values that are related to the list item but may be dynamic or conditional in some way, and it makes more sense to evaluate those conditions using JavaScript at render time than to use something like, say, a Calculated Column, because a Calculated Column is only re-calculated when the item is updated.

My original approach to implementing this kind of thing was to add a column to the list as a place-holder for where my dynamic value would be rendered, and then link the CSR override script to that field using the field's JSLink property.

However, at the recent SharePointFest DC conference, I was at a talk about CSR, and it was mentioned that, from a data integrity standpoint, that is a bad practice because you are essentially adding a field to the list in which you are never going to store any data.  It was also mentioned that you could probably just add a field directly in the view you do your rendering on.

I thought that sounded reasonable, and was an interesting approach, so I played around with the idea for a little bit and came up with this general pattern for doing just that.

And instead of attaching to the JSLink property of a field, these scripts are intended to be added directly to the list Views through the List View Web Part's JSLink property (which is exposed in the web part's Editor pane).

During my experimentation, I was trying to add the temporary column in OnPreRender, but still register a field override for that field in the normal manner.  That turned out to be problematic, so eventually I settled on using OnPreRender to add the temporary field to the view, and using OnPostRender to do all the calculations and render the final HTML.

Here's a code snippet that outlines the basic pattern, which I have commented heavily so you can follow along:

  1. var DEC = DEC || {};
  2.  
  3. DEC.FakeField = (function () {
  4.  
  5.     function addTemporaryField(ctx) {
  6.         // make sure we haven't added the field already,
  7.         // in case OnPreRender fires twice, which it does sometimes
  8.         if (ctx.ListSchema.Field.filter(function (fld) {
  9.             return fld.Name == 'FakeField';
  10.         }).length == 0) {
  11.  
  12.             // create the field schema object to insert
  13.             var FakeFieldObj = {
  14.                 AllowGridEditing: 'FALSE',
  15.                 DisplayName: 'Fake Field',
  16.                 RealFieldName: 'FakeField',
  17.                 Name: 'FakeField',
  18.                 FieldType: 'Text',
  19.                 Type: 'Text',
  20.                 Filterable: 'FALSE',
  21.                 Sortable: 'FALSE',
  22.                 ReadOnly: 'TRUE',
  23.             };
  24.  
  25.             // find the index of the field to insert next to,
  26.             // based on that field's DISPLAY name.
  27.             var insertIdx = ctx.ListSchema.Field.map(function (fld) {
  28.                 return fld.DisplayName;
  29.             }).indexOf('Some Field Name');
  30.  
  31.             // if you want to insert *before* the field do not add anything
  32.             // but, if you want to insert *after* the field, add 1
  33.             insertIdx++;
  34.  
  35.             // insert your fake field schema object into the list schema
  36.             ctx.ListSchema.Field.splice(insertIdx, 0, FakeFieldObj);
  37.         }
  38.     }
  39.  
  40.     function renderFakeFieldValues(ctx) {
  41.         // get the index of the fake field column in the table
  42.         // by looking for the header with the DISPLAY NAME of our fake field,
  43.         // so we know where to insert the final rendered value for each item
  44.         var colIndex = 0;
  45.         var headers = document.querySelectorAll('.ms-listviewtable th');
  46.         var len = headers.length;
  47.         for (var idx = 0; idx < len; idx++) {
  48.             if (headers[idx].innerHTML.indexOf('Fake Field') != -1) {
  49.                 colIndex = idx;
  50.             }
  51.         };
  52.  
  53.         // go through the rows and do whatever calculations we need
  54.         // to get the value we want to render in the added field.
  55.         // at this point ctx.ListData.Row represents a collection
  56.         // of list items for all the rows currently visible in the view.
  57.         ctx.ListData.Row.forEach(function (listItem) {
  58.  
  59.             // here, listItem will have all the data of all
  60.             // the columns visible in the view, which we
  61.             // can access by listItem.FieldInternalName
  62.  
  63.             // we can then use those values to do our conditional formatting
  64.             // and calculate / generate the value to render in the fake field
  65.             var helloWorldHtml = '<span>Hello ' + listItem.WorldField + '</span>';
  66.  
  67.             // find the row in the table that corresponds to this list item
  68.             var iid = GenerateIIDForListItem(ctx, listItem);
  69.             var row = document.getElementById(iid);
  70.  
  71.             // insert the value we want in the fake field column
  72.             // of the row representing this list item
  73.             row.children[colIndex].innerHTML = helloWorldHtml;
  74.         });
  75.     }
  76.  
  77.     return {
  78.         render: function () {
  79.             SPClientTemplates.TemplateManager.RegisterTemplateOverrides({
  80.                 OnPreRender: addTemporaryField,
  81.                 OnPostRender: renderFakeFieldValues
  82.             });
  83.         }
  84.     }
  85. })();
  86.  
  87. RegisterModuleInit(SPClientTemplates.Utility.ReplaceUrlTokens("~site/SiteAssets/Scripts/AddFakeField.js"), DEC.FakeField.render);
  88. DEC.FakeField.render();

So where is this useful?  Well, let's say you want to highlight list items that are overdue.  Sure, you could come up with a rendering override for the due date field itself, and either render the text in red, or add a red background, or something like that.  But what if you wanted to display a message to the users that spelled out exactly how many days overdue the item was, like "XX days overdue".

The following code snippet shows how to do exactly that:

  1. var DEC = DEC || {};
  2.  
  3. DEC.DaysOverdueField = (function () {
  4.  
  5.     function addTemporaryField(ctx) {
  6.  
  7.         if (ctx.ListSchema.Field.filter(function (fld) {
  8.             return fld.Name == 'DaysOverdueField';
  9.         }).length == 0) {
  10.  
  11.             var daysOverdueFieldObj = {
  12.                 AllowGridEditing: 'FALSE',
  13.                 DisplayName: 'Days Overdue',
  14.                 RealFieldName: 'DaysOverdueField',
  15.                 Name: 'DaysOverdueField',
  16.                 FieldType: 'Text',
  17.                 Type: 'Text',
  18.                 Filterable: 'FALSE',
  19.                 Sortable: 'FALSE',
  20.                 ReadOnly: 'TRUE',
  21.             };
  22.  
  23.             // in this case i am going to show the "XX days overdue"
  24.             // message directly next to the due date field
  25.             var insertIdx = ctx.ListSchema.Field.map(function (fld) {
  26.                 return fld.DisplayName;
  27.             }).indexOf('Due Date');
  28.  
  29.             // insert *after* the due date field
  30.             insertIdx++;
  31.  
  32.             ctx.ListSchema.Field.splice(insertIdx, 0, daysOverdueFieldObj);
  33.         }
  34.     }
  35.  
  36.     function calculateOverdueDays(ctx) {
  37.         // get the index of the "Days Overdue" column in the table
  38.         var colIndex = 0;
  39.         var headers = document.querySelectorAll('.ms-listviewtable th');
  40.         var len = headers.length;
  41.         for (var idx = 0; idx < len; idx++) {
  42.             if (headers[idx].innerHTML.indexOf('Days Overdue') != -1) {
  43.                 colIndex = idx;
  44.             }
  45.         };
  46.  
  47.         // go through the rows and display an overdue message if it is overdue
  48.         ctx.ListData.Row.forEach(function (listItem) {
  49.             // get the due date
  50.             var duedate = new Date(listItem.DueDate);
  51.  
  52.             // use the built in GetDaysAfterToday function to calculate
  53.             // how many days overdue an item is.  if it is truly overdue,
  54.             // this function will return a negative number.
  55.             var overdueDays = GetDaysAfterToday(duedate);
  56.             if (overdueDays < 0) {
  57.  
  58.                 // change the negative to a positive
  59.                 overdueDays = Math.abs(overdueDays);
  60.  
  61.                 // find the row in the table that corresponds to this list item
  62.                 var iid = GenerateIIDForListItem(ctx, listItem);
  63.                 var row = document.getElementById(iid);
  64.  
  65.                 // insert the overdue message in the <td> element in our temporary "Days Overdue" column
  66.                 row.children[colIndex].innerHTML = '<span style="color:red;">' + overdueDays + ' days overdue</span>';
  67.             }
  68.         });
  69.     }
  70.  
  71.     return {
  72.         render: function () {
  73.             SPClientTemplates.TemplateManager.RegisterTemplateOverrides({
  74.                 OnPreRender: addTemporaryField,
  75.                 OnPostRender: calculateOverdueDays
  76.             });
  77.         }
  78.     }
  79. })();
  80.  
  81. RegisterModuleInit(SPClientTemplates.Utility.ReplaceUrlTokens("~site/SiteAssets/Scripts/ShowDaysOverdue.js"), DEC.DaysOverdueField.render);
  82. DEC.DaysOverdueField.render();

So, that is how you can add a temporary column to a list view, and then access it to add dynamic information related to the list items into the view.  As requested, here is a screen shot of the "days overdue" code in action.  The column "Days Overdue" does not exist on this list:



In a subsequent post I will show how to use this technique to do something a little more complex: display file counts next to folders in a Document Library view that includes all files within all subfolders within any particular folder.

7 comments:

  1. Dylan, I like the sound of this, but am having trouble picturing it. Do you have a screenshot you could add?

    ReplyDelete
    Replies
    1. Hi Jim, as requested I have added a screen shot of the "days overdue" code in action. Let me know if you need any further clarification.

      Delete
  2. Hello,

    I would need to load column names from another list.
    So my list "ColumnNames" contains for example 10 records, each describing the name of the column to add in "onPreRender".

    However, I can't use ctx it seems to do a Caml Query to get those records.
    I can do it the normal way (using var context = SP.ClientContext.get_current();) but then I first need to load sp.js (via executeOrDelayUntilScriptLoaded), otherwise SP.ClientContext.get_current() is undefined.

    The issue is, if I delay (via executeOrDelayUntilScriptLoaded), it seems i'm "too late" in the sequence to still be able to add columns in prerender.
    The window of opportunity has passed and my columns are not added anymore.
    A quick test is just to do the same with hardcoded columnnames:

    addTemporaryField(ctx, columnToAddFieldsAfter, "TestColumn1", "Test Column 1"); // works

    SP.SOD.executeOrDelayUntilScriptLoaded(loadSaleOfDataColumns, 'sp.js');
    function loadSaleOfDataColumns() {
    addTemporaryField(ctx, columnToAddFieldsAfter, "TestColumn2", "Test Column 2"); // not working
    var context = SP.ClientContext.get_current(); // works
    }

    Any ideas on how to load data via ctx so that i don't need to 'wait' on sp.js?

    ReplyDelete
    Replies
    1. Hi Peter, unfortunately I can't think of a good way to do that.

      CSR happens relatively early in the lifecycle of a page, and there's no way to make it wait for asynchronous calls to finish and return data. My usual way of handling CSR overrides when needing to get extra data asynchronously is to use CSR to render placeholder <div>s with custom IDs that I dictate, and then in the OnPostRender phase of the rendering, kick off the async data fetching, and in the callback from that, populate the placeholder <div>s with the data returned.

      In your case, though, that won't work since you need the column names already in OnPreRender in order to insert them into the ctx.ListSchema.Field collection to have them show up as fake fields in the view.

      Since you can't make CSR wait, and you can't insert fake fields after the view has been rendered, I'm afraid you're out of luck with this particular scenario.

      Delete
    2. OK, that was my thinking too. I currently solved it by declaring an array of constant values and loop through that one.
      It requires double maintenance in case values change, but at least it is kind of isolated for easier management if this happens.

      Thanks for your your effort and for the great post! Much better than poluting the SP List with unused columns.

      Delete
  3. Does this work on Modern SharePoint lists / libraries / pages?

    ReplyDelete
    Replies
    1. I'm pretty sure the answer to that is, unfortunately, no. My understanding is that the Client Side Rendering framework and JSLink are not present in Modern pages and lists. If I were to try to implement something like this for Modern lists I would be looking into using column formatting (JSON formatting), or field customizers. I have not used either of those, but my understanding is those are the tools available for Modern sites to do this kind of thing.

      Delete