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.